Skip to main content

nomoreide_daemon/
server.rs

1//! Booting the loopback daemon: take ownership, bind, publish, serve, drain.
2//! What it serves lives in [`routes`].
3
4mod app;
5mod body;
6mod errors;
7mod query;
8mod routes;
9mod sse;
10mod static_assets;
11
12use crate::runtime::DaemonRuntime;
13use crate::server::routes::deploy_providers::oauth::ProviderLogins;
14use crate::DaemonOwnership;
15use anyhow::{Context, Result};
16use app::AppState;
17use chrono::Utc;
18use nomoreide_core::agent_profiles::auth::AuthStates;
19use nomoreide_core::approval_broker::ApprovalBroker;
20use nomoreide_core::config::ConfigStore;
21use nomoreide_core::error_inbox::ErrorInbox;
22use nomoreide_core::log_store::LogStore;
23use nomoreide_core::metrics_store::MetricsStore;
24use nomoreide_core::process_manager::ProcessManager;
25use nomoreide_core::runtime_registry::RuntimeRegistry;
26use nomoreide_core::terminal::TerminalManager;
27use nomoreide_core::test_runner::TestRunner;
28use nomoreide_core::timeline::TimelineStore;
29use nomoreide_core::tool_call_store::ToolCallStore;
30use nomoreide_core::usage_history::UsageHistory;
31use nomoreide_daemon_client::{DaemonState, RuntimePaths};
32use std::future::Future;
33use std::net::{Ipv4Addr, SocketAddr};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::time::Duration;
37use tokio::net::TcpListener;
38use tokio::sync::{mpsc, oneshot};
39
40#[derive(Debug, Clone)]
41pub struct DaemonOptions {
42    pub port: u16,
43    pub runtime_paths: RuntimePaths,
44    pub config_path: PathBuf,
45}
46
47impl Default for DaemonOptions {
48    fn default() -> Self {
49        Self {
50            port: nomoreide_daemon_client::DEFAULT_DAEMON_PORT,
51            runtime_paths: RuntimePaths::default(),
52            config_path: ConfigStore::default_path(),
53        }
54    }
55}
56
57pub async fn run(options: DaemonOptions) -> Result<()> {
58    let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
59    tokio::spawn(forward_shutdown_signals(shutdown_tx.clone()));
60    serve_with_shutdown_requests(options, shutdown_tx, shutdown_rx).await
61}
62
63/// Run the daemon on a listener the caller has already bound.
64///
65/// The desktop app uses this to reserve its private ephemeral port before its
66/// webview exists, removing the gap where another process could claim the port
67/// between discovery and the HTTP server binding it.
68pub async fn run_with_listener(options: DaemonOptions, listener: TcpListener) -> Result<()> {
69    let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
70    tokio::spawn(forward_shutdown_signals(shutdown_tx.clone()));
71    serve_on_listener(
72        options,
73        listener,
74        RuntimePublication::Files,
75        shutdown_tx,
76        shutdown_rx,
77    )
78    .await
79}
80
81/// Run an app-private daemon whose connection details never reach disk.
82///
83/// The caller owns both the listener and credential, so it can hand the latter
84/// directly to its webview before loading the dashboard. Runtime logs and the
85/// crash-recovery registry still live under `runtime_paths`; only discovery
86/// state and the bearer credential remain in memory.
87pub async fn run_embedded(
88    options: DaemonOptions,
89    listener: TcpListener,
90    credential: String,
91) -> Result<()> {
92    let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
93    run_embedded_with_shutdown_requests(options, listener, credential, shutdown_tx, shutdown_rx)
94        .await
95}
96
97/// Run an app-private daemon with a shutdown channel owned by its host.
98///
99/// The desktop app uses the retained sender to drain every managed service
100/// before its process exits. HTTP shutdown requests use the same channel, so
101/// both paths have identical cleanup semantics.
102pub async fn run_embedded_with_shutdown_requests(
103    options: DaemonOptions,
104    listener: TcpListener,
105    credential: String,
106    shutdown_sender: mpsc::Sender<ShutdownRequest>,
107    shutdown_requests: mpsc::Receiver<ShutdownRequest>,
108) -> Result<()> {
109    anyhow::ensure!(
110        !credential.is_empty(),
111        "embedded daemon credential is empty"
112    );
113    serve_on_listener(
114        options,
115        listener,
116        RuntimePublication::Memory(credential),
117        shutdown_sender,
118        shutdown_requests,
119    )
120    .await
121}
122
123pub async fn serve_until<F>(options: DaemonOptions, shutdown: F) -> Result<()>
124where
125    F: Future<Output = ()> + Send + 'static,
126{
127    let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
128    let signalled = shutdown_tx.clone();
129    tokio::spawn(async move {
130        shutdown.await;
131        let _ = signalled.send(ShutdownRequest::Signalled).await;
132    });
133    serve_with_shutdown_requests(options, shutdown_tx, shutdown_rx).await
134}
135
136/// `shutdown_requests` is handed in as both ends: the receiver drains the
137/// runtime, and the sender is what `POST /api/daemon/shutdown` pulls on, so a
138/// request and a signal reach the same drain rather than two separate exits.
139pub async fn serve_with_shutdown_requests(
140    options: DaemonOptions,
141    shutdown_sender: mpsc::Sender<ShutdownRequest>,
142    shutdown_requests: mpsc::Receiver<ShutdownRequest>,
143) -> Result<()> {
144    let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, options.port)))
145        .await
146        .context("failed to bind the daemon loopback listener")?;
147    serve_on_listener(
148        options,
149        listener,
150        RuntimePublication::Files,
151        shutdown_sender,
152        shutdown_requests,
153    )
154    .await
155}
156
157/// Why a shutdown was asked for.
158///
159/// **The distinction is the whole point.** Cleanup failing used to refuse the
160/// shutdown whatever asked for it, and that is right for one of these two and
161/// catastrophic for the other: a daemon whose cleanup can *never* succeed —
162/// because its state directory has been deleted out from under it, which is
163/// what happens to a parity gate's temp fixture — becomes immortal. Four of
164/// them were found alive on this machine, up to ten days old, ignoring
165/// `SIGTERM` and holding a listening socket on a directory that no longer
166/// existed.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ShutdownRequest {
169    /// `POST /api/daemon/shutdown`. A caller asked and is waiting for an
170    /// answer, so a cleanup failure is worth reporting *and* worth staying up
171    /// for — the next request reaches a daemon that knows it has processes it
172    /// could not account for.
173    Requested,
174    /// `SIGTERM`, `SIGINT`, or the future handed to [`serve_until`].
175    ///
176    /// Not a request that may be declined. Cleanup is still attempted and its
177    /// failure still reported, but the process exits either way: a service this
178    /// daemon could not stop is a leaked service, while refusing to exit leaks
179    /// the service *and* the daemon, forever, with nothing left that can ask it
180    /// again.
181    Signalled,
182}
183
184enum RuntimePublication {
185    Files,
186    Memory(String),
187}
188
189async fn serve_on_listener(
190    options: DaemonOptions,
191    listener: TcpListener,
192    publication: RuntimePublication,
193    shutdown_sender: mpsc::Sender<ShutdownRequest>,
194    shutdown_requests: mpsc::Receiver<ShutdownRequest>,
195) -> Result<()> {
196    let ownership = DaemonOwnership::acquire(options.runtime_paths.clone())
197        .context("failed to acquire daemon ownership")?;
198    let config_store = ConfigStore::new(options.config_path);
199    // One timeline, shared by the two things that write to it: the log store
200    // raises an event for a line that classified as notable, and the process
201    // manager raises one for each lifecycle moment.
202    let timeline = TimelineStore::new(options.runtime_paths.state_dir.join("timeline.log"));
203    let log_store =
204        LogStore::new(options.runtime_paths.state_dir.join("logs")).with_timeline(timeline.clone());
205    let registry = RuntimeRegistry::new(
206        options
207            .runtime_paths
208            .state_dir
209            .join("native")
210            .join("runtime-v1.json"),
211    );
212    // The inbox reads the same lines the log store keeps, so it is built over
213    // that store and told to watch before any service can produce one.
214    let errors = ErrorInbox::new(log_store.clone());
215    errors.watch();
216    // Built over the same store, so a failing run's output reaches the inbox
217    // the way a service's does — that is what turns a failed test into an
218    // incident with no extra wiring.
219    let tests = TestRunner::new(log_store.clone());
220    let runtime = Arc::new(DaemonRuntime::new(
221        config_store.clone(),
222        ProcessManager::with_runtime_registry(log_store, registry).with_timeline(timeline),
223    ));
224    // Whatever a crashed owner left behind is reclaimed before this one binds a
225    // port or publishes a credential, so nothing can reach a half-owned runtime.
226    runtime
227        .reconcile_runtime()
228        .await
229        .context("failed to reconcile the native runtime registry")?;
230
231    let address = listener
232        .local_addr()
233        .context("failed to inspect the daemon listener")?;
234    let state = DaemonState {
235        pid: std::process::id(),
236        owner_id: ownership.owner_id().to_string(),
237        url: format!("http://127.0.0.1:{}", address.port()),
238        port: address.port(),
239        version: Some(env!("CARGO_PKG_VERSION").into()),
240        started_at: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
241    };
242    let (credential, embedded) = match publication {
243        RuntimePublication::Files => {
244            ownership
245                .publish(&state)
246                .context("failed to publish daemon state")?;
247            (ownership.credential().to_string(), false)
248        }
249        RuntimePublication::Memory(credential) => (credential, true),
250    };
251
252    // Token and cost history, beside the logs and the timeline in the same
253    // state directory. The reference derives this path from its log directory
254    // for the same reason: one place per machine, not one per project.
255    // Anchored to the daemon's own working directory, which is the filesystem
256    // whose free space the dashboard reports.
257    let metrics = MetricsStore::new(crate::server::routes::daemon_cwd());
258    // One sample before anything is served, so the first request to reach a
259    // freshly started daemon draws a point rather than an empty pane. The
260    // reference has the same property by starting its sampler before it binds.
261    metrics.sample_once(&[]).await;
262    tokio::spawn(sample_metrics(metrics.clone(), runtime.clone()));
263
264    let usage_history = Arc::new(UsageHistory::new(
265        options.runtime_paths.state_dir.join("usage-history.jsonl"),
266    ));
267    tokio::spawn(sample_usage(usage_history.clone()));
268
269    // Exit if this daemon's runtime home is deleted out from under it. See
270    // `watch_runtime_home` — this is what stops an orphaned gate daemon living
271    // for ten days rather than merely making it killable.
272    tokio::spawn(watch_runtime_home(
273        options.runtime_paths.clone(),
274        ownership.owner_id().to_string(),
275        shutdown_sender.clone(),
276    ));
277
278    // One channel behind the sink every manager already emits into, so the
279    // terminal stream is a subscriber rather than a change to the manager.
280    let event_stream = tokio::sync::broadcast::Sender::<app::RuntimeEvent>::new(app::EVENT_BACKLOG);
281    // The dispatcher calls this router in-process and has to present the same
282    // credential a browser does, so it gets its own copy before the state takes
283    // ownership. It is not a second, more privileged way in — it is the same
284    // door.
285    // Hoisted out of the state literal below so the relay mirrors *these*
286    // sessions. Two managers would each own their own PTYs, and a phone would
287    // be shown a terminal list the dashboard has never heard of.
288    let terminal = TerminalManager::new();
289    let events: nomoreide_core::event_sink::SharedEventSink =
290        Arc::new(app::BroadcastEventSink::new(event_stream.clone()));
291    #[cfg(unix)]
292    let _attach_server = nomoreide_core::terminal::attach::serve(
293        &options.runtime_paths.state_dir,
294        terminal.clone(),
295        events.clone(),
296    )
297    .map_err(anyhow::Error::msg)
298    .context("failed to start local terminal attachment")?;
299    let relay = crate::remote::supervisor::RelaySupervisor::new(
300        options.runtime_paths.state_dir.clone(),
301        credential.clone(),
302        terminal.clone(),
303    );
304    let app = routes::router(AppState {
305        credential,
306        owner_id: ownership.owner_id().to_string(),
307        config_store,
308        runtime: runtime.clone(),
309        errors,
310        shutdown: shutdown_sender,
311        terminal,
312        events,
313        event_stream,
314        session_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
315        metrics: metrics.clone(),
316        tool_calls: ToolCallStore::new(),
317        tests,
318        usage_history,
319        approvals: ApprovalBroker::new(),
320        registry_auth: AuthStates::new(),
321        provider_logins: ProviderLogins::new(),
322        relay: relay.clone(),
323        pending_pairing: Default::default(),
324    });
325    let app = if embedded {
326        app.layer(axum::middleware::from_fn(routes::allow_desktop_origin))
327    } else {
328        app
329    };
330
331    // Only the machine-global daemon dials the relay. The desktop app runs its
332    // own in-process, and two daemons sharing one credential would leave a
333    // phone talking to whichever restarted last — see `crate::remote`.
334    if !embedded {
335        // The router exists now, so the dispatcher has something to call. A
336        // machine already paired connects here; one paired later connects when
337        // `nomoreide remote pair` asks, without a restart.
338        relay.attach_router(app.clone());
339        relay.ensure_started();
340    }
341
342    let (http_shutdown_tx, http_shutdown_rx) = oneshot::channel();
343    let shutdown_coordinator = tokio::spawn(drain_before_shutdown(
344        runtime.clone(),
345        shutdown_requests,
346        http_shutdown_tx,
347    ));
348
349    let server_result = axum::serve(listener, app)
350        .with_graceful_shutdown(async {
351            let _ = http_shutdown_rx.await;
352        })
353        .await;
354    shutdown_coordinator.abort();
355    server_result.context("daemon HTTP server failed")?;
356    drop(ownership);
357    Ok(())
358}
359
360/// Stop serving only once the services are actually down — unless the ask was a
361/// signal, which is not a thing to decline.
362///
363/// A [`ShutdownRequest::Requested`] that cannot clean up stays up and says so,
364/// so the next request reaches a daemon that knows it has processes it could
365/// not account for. A [`ShutdownRequest::Signalled`] that cannot clean up
366/// exits anyway — see the type for the four immortal daemons that behaviour
367/// cost.
368async fn drain_before_shutdown(
369    runtime: Arc<DaemonRuntime>,
370    mut requests: mpsc::Receiver<ShutdownRequest>,
371    http_shutdown: oneshot::Sender<()>,
372) {
373    let mut http_shutdown = Some(http_shutdown);
374    loop {
375        let Some(request) = requests.recv().await else {
376            std::future::pending::<()>().await;
377            continue;
378        };
379        match runtime.shutdown().await {
380            Ok(()) => {
381                if let Some(sender) = http_shutdown.take() {
382                    let _ = sender.send(());
383                }
384                return;
385            }
386            Err(error) => {
387                eprintln!("nomoreide: daemon cleanup failed: {error}");
388                if stops_anyway(request) {
389                    eprintln!("nomoreide: exiting anyway; a signal is not a request to decline.");
390                    if let Some(sender) = http_shutdown.take() {
391                        let _ = sender.send(());
392                    }
393                    return;
394                }
395                eprintln!("nomoreide: shutdown refused; send SIGTERM to stop regardless.");
396            }
397        }
398    }
399}
400
401/// How often the daemon checks that it still has a runtime home.
402const RUNTIME_HOME_POLL: Duration = Duration::from_secs(30);
403/// Consecutive misses before exiting. Two, so a transient stat failure — a
404/// filesystem briefly unavailable, a home on a network mount — does not end a
405/// healthy daemon.
406const RUNTIME_HOME_MISSES: u8 = 2;
407
408/// Stop when this daemon's runtime home has been deleted out from under it.
409///
410/// **This is what makes orphan cleanup automatic** rather than something a
411/// person notices weeks later in `ps`. A parity gate spawns a daemon inside a
412/// temp fixture; when the gate is interrupted the fixture is removed and the
413/// daemon is left serving a directory that no longer exists. Nothing ever told
414/// it to stop, so it did not — four such daemons were found on one machine, up
415/// to ten days old.
416///
417/// **The check is the lock file, deliberately not the parent process.** The
418/// obvious test — "am I an orphan, is my `ppid` 1?" — is exactly wrong here:
419/// the real daemon is *detached on purpose* and legitimately has `ppid` 1 from
420/// the moment it starts. A parent-death check would kill the one daemon that is
421/// supposed to be running. What actually separates the two is that
422/// `~/.nomoreide/daemon.lock` persists while a temp fixture does not.
423///
424/// An owner id that no longer matches counts as gone too: the file being
425/// replaced means this process is no longer the owner it published itself as,
426/// and serving on a credential nobody can look up is not serving.
427async fn watch_runtime_home(
428    paths: RuntimePaths,
429    owner_id: String,
430    shutdown: mpsc::Sender<ShutdownRequest>,
431) {
432    let mut misses = 0u8;
433    loop {
434        tokio::time::sleep(RUNTIME_HOME_POLL).await;
435        if runtime_home_is_ours(&paths, &owner_id) {
436            misses = 0;
437            continue;
438        }
439        misses += 1;
440        if misses < RUNTIME_HOME_MISSES {
441            continue;
442        }
443        eprintln!(
444            "nomoreide: runtime home {} is gone; stopping.",
445            paths.state_dir.display()
446        );
447        // `Signalled`, not `Requested`: nobody is waiting for an answer, and
448        // this must not be declinable — a daemon whose home has been deleted is
449        // precisely the one whose cleanup cannot succeed.
450        let _ = shutdown.send(ShutdownRequest::Signalled).await;
451        return;
452    }
453}
454
455/// Whether the lock file still exists and still names this owner.
456fn runtime_home_is_ours(paths: &RuntimePaths, owner_id: &str) -> bool {
457    let Ok(raw) = std::fs::read(&paths.lock) else {
458        return false;
459    };
460    // A lock file that cannot be parsed is not evidence of anything; treat it
461    // as present rather than reading a truncated write as a reason to exit.
462    match serde_json::from_slice::<crate::LockRecord>(&raw) {
463        Ok(record) => record.owner_id == owner_id,
464        Err(_) => true,
465    }
466}
467
468/// Whether a shutdown proceeds even though cleanup failed.
469///
470/// The one line the immortal-daemon bug turned on, pulled out of the loop so a
471/// test holds it rather than a comment.
472fn stops_anyway(request: ShutdownRequest) -> bool {
473    matches!(request, ShutdownRequest::Signalled)
474}
475
476async fn forward_shutdown_signals(sender: mpsc::Sender<ShutdownRequest>) {
477    #[cfg(unix)]
478    {
479        use tokio::signal::unix::{signal, SignalKind};
480        let Ok(mut terminate) = signal(SignalKind::terminate()) else {
481            return;
482        };
483        let Ok(mut interrupt) = signal(SignalKind::interrupt()) else {
484            return;
485        };
486        loop {
487            tokio::select! {
488                _ = terminate.recv() => {}
489                _ = interrupt.recv() => {}
490            }
491            if sender.send(ShutdownRequest::Signalled).await.is_err() {
492                return;
493            }
494        }
495    }
496    #[cfg(not(unix))]
497    loop {
498        if tokio::signal::ctrl_c().await.is_err()
499            || sender.send(ShutdownRequest::Signalled).await.is_err()
500        {
501            return;
502        }
503    }
504}
505
506/// Record the current reading shortly after boot, and every thirty seconds
507/// after that.
508///
509/// Always on, rather than driven by the Usage tab: history that only accrues
510/// while someone is watching is not history. The store de-dupes, so an idle
511/// agent costs one read of three files a tick and writes nothing, and a failure
512/// is dropped rather than logged — a sample is not worth a line in the daemon's
513/// stderr every thirty seconds when a home has gone read-only.
514async fn sample_usage(history: Arc<UsageHistory>) {
515    let cwd = crate::server::routes::daemon_cwd();
516    // Deferred, so a daemon that starts and stops immediately leaves no file
517    // behind at all.
518    tokio::time::sleep(Duration::from_secs(5)).await;
519    loop {
520        let usage = nomoreide_core::usage_info::build_usage_info(&cwd).await;
521        history.record(&usage).await;
522        tokio::time::sleep(Duration::from_secs(30)).await;
523    }
524}
525
526/// Sample host and per-service activity on a timer.
527///
528/// The first tick is immediate rather than deferred: a dashboard opened at the
529/// same moment as the daemon should draw a point, not an empty pane, and one
530/// `ps` at startup costs nothing anybody notices.
531async fn sample_metrics(metrics: MetricsStore, runtime: Arc<DaemonRuntime>) {
532    let interval = Duration::from_millis(metrics.interval_ms());
533    loop {
534        // Sleep first. The startup sample has already been taken, and taking a
535        // second one straight away would give the host a CPU percentage before
536        // an interval had passed -- a ratio over no elapsed time.
537        tokio::time::sleep(interval).await;
538        let running: Vec<nomoreide_core::metrics_store::RunningService> = runtime
539            .status()
540            .into_iter()
541            .filter(|status| {
542                serde_json::to_value(status.state)
543                    .ok()
544                    .and_then(|value| value.as_str().map(str::to_string))
545                    .as_deref()
546                    == Some("running")
547            })
548            .map(|status| nomoreide_core::metrics_store::RunningService {
549                name: status.name,
550                pid: status.pid.map(i64::from),
551                started_at: status.started_at,
552            })
553            .collect();
554        metrics.sample_once(&running).await;
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::{runtime_home_is_ours, stops_anyway, RuntimePaths, ShutdownRequest};
561
562    /// The regression, stated as the rule it broke.
563    ///
564    /// Cleanup failing used to refuse the shutdown whatever asked for it. For a
565    /// signal that is not a refusal anybody can act on — there is nothing left
566    /// to ask again — so the daemon lived forever. Four were found on one
567    /// machine, up to ten days old, holding sockets on deleted directories.
568    #[test]
569    fn a_signal_stops_the_daemon_even_when_cleanup_fails() {
570        assert!(stops_anyway(ShutdownRequest::Signalled));
571    }
572
573    /// A deleted runtime home is what an orphaned gate daemon is left serving.
574    #[test]
575    fn a_missing_lock_file_means_the_runtime_home_is_gone() {
576        let dir = std::env::temp_dir().join(format!("nmi-home-{}", uuid::Uuid::new_v4()));
577        let paths = RuntimePaths::new(dir.clone());
578        assert!(!runtime_home_is_ours(&paths, "owner-a"));
579    }
580
581    #[test]
582    fn a_lock_naming_another_owner_means_this_daemon_is_not_it() {
583        let dir = std::env::temp_dir().join(format!("nmi-home-{}", uuid::Uuid::new_v4()));
584        std::fs::create_dir_all(&dir).unwrap();
585        let paths = RuntimePaths::new(dir.clone());
586        std::fs::write(&paths.lock, br#"{"pid":1,"ownerId":"owner-b"}"#).unwrap();
587        assert!(!runtime_home_is_ours(&paths, "owner-a"));
588        assert!(runtime_home_is_ours(&paths, "owner-b"));
589        // A half-written file is not evidence of anything, so it is kept.
590        std::fs::write(&paths.lock, b"{not json").unwrap();
591        assert!(runtime_home_is_ours(&paths, "owner-a"));
592        std::fs::remove_dir_all(&dir).ok();
593    }
594
595    /// ...and the half that was right stays right: an HTTP caller is waiting
596    /// for an answer and can retry, so a daemon that could not account for its
597    /// services stays up and says so.
598    #[test]
599    fn an_http_request_still_refuses_when_cleanup_fails() {
600        assert!(!stops_anyway(ShutdownRequest::Requested));
601    }
602}