Skip to content

Commit 6468f87

Browse files
norrietaylorclaude
andcommitted
refactor(minimald): encapsulate daemon logging in a DaemonLogger type
Pull the appender creation, the reloadable tracing layer, and the shutdown release out of init_tracing/main/server and into one `DaemonLogger` (new `logging` module). The native detached daemon and the microVM pid-1 now share one path: install() sets up console + an inert reloadable file slot for both, activate() points it at the log directory once known (immediately for the native daemon, post-mount for the microVM), and both hand the resulting release to ServerState to run at shutdown. This removes the (WorkerGuard, LogActivator) bifurcation and the type_complexity allow. The file-log release (renamed VolumeLogRelease -> DaemonLogRelease) now runs on shutdown in both cases, not only when a volume is mounted; for the microVM it still precedes the quiesce so the appender's fd is gone before the unmount. ServerState owns the release value; DaemonLogger authors its logic. Refs: #801 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 457119f commit 6468f87

4 files changed

Lines changed: 215 additions & 216 deletions

File tree

crates/minimald/src/logging.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//! The daemon's on-disk log in one place: appender creation, the reloadable
2+
//! tracing layer, and the shutdown release. The native detached daemon and
3+
//! the microVM pid-1 share this machinery unchanged — they differ only in
4+
//! *when* the log directory is final (immediately for the native daemon,
5+
//! after the state volume mounts for the microVM). A foreground run logs to
6+
//! stdout only and never activates a file.
7+
//!
8+
//! [`DaemonLogger::install`] assembles the global subscriber;
9+
//! [`DaemonLogger::activate`] points the file layer at a directory once it is
10+
//! known and yields a [`DaemonLogRelease`](minimald::server::DaemonLogRelease)
11+
//! for [`ServerState`](minimald::server::ServerState) to run at shutdown.
12+
13+
use std::path::Path;
14+
15+
use minimald::server::DaemonLogRelease;
16+
use tracing_subscriber::layer::SubscriberExt as _;
17+
use tracing_subscriber::util::SubscriberInitExt as _;
18+
use tracing_subscriber::{EnvFilter, Layer as _, fmt};
19+
20+
use crate::MainError;
21+
22+
/// Where a daemon's records go.
23+
pub enum LogMode {
24+
/// Foreground: stdout only, no file.
25+
Console,
26+
/// Detached daemon or microVM pid-1: stdout (which the VM routes over the
27+
/// serial console into the host `boot.log`) plus a reloadable file layer,
28+
/// pointed at a directory by [`DaemonLogger::activate`].
29+
File,
30+
}
31+
32+
type Activator = Box<dyn FnOnce(&Path) -> Result<DaemonLogRelease, MainError> + Send>;
33+
34+
/// The daemon's logging, once installed into the global tracing subscriber.
35+
pub struct DaemonLogger {
36+
/// Present when [`install`](Self::install) set up a file layer (both file
37+
/// modes); `None` for a console-only foreground run.
38+
activate: Option<Activator>,
39+
}
40+
41+
impl DaemonLogger {
42+
/// Assemble and install the global subscriber for `mode`. Call once,
43+
/// before any `tracing::*` whose output should be captured.
44+
pub fn install(mode: LogMode) -> Result<Self, MainError> {
45+
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
46+
EnvFilter::new("info")
47+
.add_directive("topiary=off".parse().unwrap())
48+
.add_directive("libcgroups=off".parse().unwrap())
49+
});
50+
51+
let LogMode::File = mode else {
52+
tracing_subscriber::registry()
53+
.with(fmt::layer().with_writer(ot::StdoutWriter::new))
54+
.with(filter)
55+
.init();
56+
return Ok(Self { activate: None });
57+
};
58+
59+
// The file layer starts `None` (inert — records still reach the
60+
// console). `reload` lets `activate` swap the appender in once the
61+
// log dir is known, and the shutdown release swap it back out, with
62+
// no deferred-writer hack or process-global hook.
63+
let (file_layer, reload) = tracing_subscriber::reload::Layer::new(None);
64+
tracing_subscriber::registry()
65+
.with(fmt::layer().with_writer(ot::StdoutWriter::new))
66+
.with(file_layer)
67+
.with(filter)
68+
.init();
69+
let activate: Activator = Box::new(move |log_dir: &Path| {
70+
std::fs::create_dir_all(log_dir)
71+
.map_err(|e| MainError::IO(e, "creating minimald log directory"))?;
72+
let appender = build_appender(log_dir)?;
73+
// lossy(false): a diagnostic log that drops records under load
74+
// answers the wrong question. The cost is backpressure onto
75+
// logging threads if the sink wedges — accepted, because the
76+
// console layer stays independent and the volume fallback
77+
// collects without the daemon.
78+
let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
79+
.lossy(false)
80+
.finish(appender);
81+
reload
82+
.modify(|layer| {
83+
*layer = Some(fmt::layer().with_ansi(false).with_writer(writer).boxed());
84+
})
85+
.map_err(|e| MainError::Other(format!("installing file log layer: {e}")))?;
86+
// The release: reload the file layer back off, then drop the guard
87+
// to flush pending records and close the file.
88+
Ok(DaemonLogRelease::new(move || {
89+
let _ = reload.modify(|layer| *layer = None);
90+
drop(guard);
91+
}))
92+
});
93+
Ok(Self {
94+
activate: Some(activate),
95+
})
96+
}
97+
98+
/// Point the file log at `log_dir`, returning the release to hand to
99+
/// `ServerState`. `Ok(None)` for a console-only (foreground) logger.
100+
pub fn activate(self, log_dir: &Path) -> Result<Option<DaemonLogRelease>, MainError> {
101+
self.activate.map(|f| f(log_dir)).transpose()
102+
}
103+
}
104+
105+
/// The daily-rotated, retention-bounded appender. Files carry a date suffix
106+
/// (`minimald.log.<date>`); rotation and pruning are inline (no background
107+
/// thread, no partial intermediates), so the release closes the file with a
108+
/// plain guard drop — nothing to join.
109+
fn build_appender(
110+
log_dir: &Path,
111+
) -> Result<tracing_appender::rolling::RollingFileAppender, MainError> {
112+
tracing_appender::rolling::Builder::new()
113+
.rotation(tracing_appender::rolling::Rotation::DAILY)
114+
.filename_prefix("minimald.log")
115+
// Two weeks: comfortably past "what happened last week", bounded on disk.
116+
.max_log_files(14)
117+
.build(log_dir)
118+
.map_err(|e| MainError::IO(std::io::Error::other(e), "building rotating log appender"))
119+
}

crates/minimald/src/main.rs

Lines changed: 43 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ use clap_complete::Shell;
66
use paths::{CwdRelative, Daemon, DaemonAbsPath, sub_path};
77
use std::io::Write as _;
88
use tokio::{net::UnixListener, runtime::Builder};
9-
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
109

1110
use minimald::server::{Config, HostKey, Server};
1211

12+
mod logging;
13+
use logging::{DaemonLogger, LogMode};
14+
1315
#[cfg(target_os = "linux")]
1416
use tokio_vsock::{VMADDR_CID_ANY, VsockAddr, VsockListener};
1517

@@ -347,145 +349,6 @@ fn lock_held(path: &std::path::Path) -> std::io::Result<bool> {
347349
}
348350
}
349351

350-
/// Installs the microVM's on-volume log appender post-mount, given the log
351-
/// directory. Returns the [`VolumeLogRelease`] the Shutdown RPC runs before
352-
/// quiescing the volume: it reloads the file layer off and drops the worker
353-
/// guard, closing the appender's fd. microVM pid-1 only.
354-
///
355-
/// [`VolumeLogRelease`]: minimald::server::VolumeLogRelease
356-
type LogActivator = Box<
357-
dyn FnOnce(&std::path::Path) -> Result<minimald::server::VolumeLogRelease, MainError> + Send,
358-
>;
359-
360-
/// The daily-rotated, retention-bounded appender both minimald log paths use
361-
/// (detached native daemon and microVM pid-1). Files carry a date suffix
362-
/// (`minimald.log.2026-07-20`); rotation and pruning are inline (no
363-
/// background thread, no partial intermediates), so the volume-log release
364-
/// closes the file with a plain guard drop — nothing to join.
365-
fn build_log_appender(
366-
log_dir: &std::path::Path,
367-
) -> Result<tracing_appender::rolling::RollingFileAppender, MainError> {
368-
tracing_appender::rolling::Builder::new()
369-
.rotation(tracing_appender::rolling::Rotation::DAILY)
370-
.filename_prefix("minimald.log")
371-
// Two weeks: comfortably past "what happened last week", bounded on
372-
// disk.
373-
.max_log_files(14)
374-
.build(log_dir)
375-
.map_err(|e| MainError::IO(std::io::Error::other(e), "building rotating log appender"))
376-
}
377-
378-
/// Install the tracing subscriber. Foreground processes log to stdout; a
379-
/// detached native daemon (marked by [`DETACHED_ENV`]) writes only to a
380-
/// daily-rotated `<state_dir>/logs/minimald.log`. The microVM's pid-1 minimald
381-
/// logs to stdout (serial → host boot.log, for boot diagnosis) *and* to a
382-
/// reloadable file layer that starts inert and is pointed at the state volume
383-
/// once it mounts (see async_main), so `min bug` can collect the in-VM
384-
/// daemon's logs.
385-
///
386-
/// Returns `(guard, activator)`: the [`WorkerGuard`] for the native detached
387-
/// appender (else `None`), and the microVM's [`LogActivator`] to be invoked
388-
/// post-mount (else `None`). The guard must outlive the process — dropping it
389-
/// flushes pending records and terminates the appender's worker thread.
390-
///
391-
/// [`WorkerGuard`]: tracing_appender::non_blocking::WorkerGuard
392-
#[allow(clippy::type_complexity)] // the activator alias is the complexity; one site
393-
fn init_tracing(
394-
cli: &Cli,
395-
) -> Result<
396-
(
397-
Option<tracing_appender::non_blocking::WorkerGuard>,
398-
Option<LogActivator>,
399-
),
400-
MainError,
401-
> {
402-
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
403-
EnvFilter::new("info")
404-
.add_directive("topiary=off".parse().unwrap())
405-
.add_directive("libcgroups=off".parse().unwrap())
406-
});
407-
408-
// microVM pid-1: log to the console (serial → host boot.log) *and* to a
409-
// reloadable file layer wired up once the state volume mounts (see
410-
// async_main). A microVM is never `spawn_detached`'d, so this precedes the
411-
// DETACHED check with no overlap.
412-
if is_minimal_microvm() {
413-
// The file layer starts as `None` (inert — records still reach the
414-
// console). `reload` lets async_main swap the on-volume appender in
415-
// post-mount and the Shutdown RPC swap it back out pre-quiesce,
416-
// without a hand-rolled deferred writer or a process-global hook.
417-
let (file_layer, reload_handle) = tracing_subscriber::reload::Layer::new(None);
418-
tracing_subscriber::registry()
419-
.with(fmt::layer().with_writer(ot::StdoutWriter::new))
420-
.with(file_layer)
421-
.with(filter)
422-
.init();
423-
let activator: LogActivator = Box::new(move |log_dir: &std::path::Path| {
424-
std::fs::create_dir_all(log_dir)
425-
.map_err(|e| MainError::IO(e, "creating minimald log directory"))?;
426-
let appender = build_log_appender(log_dir)?;
427-
// lossy(false): a diagnostic log that drops records under load
428-
// answers the wrong question. The cost is backpressure onto
429-
// logging threads if the volume wedges — accepted, because the
430-
// console layer stays independent and the volume fallback
431-
// collects without the daemon.
432-
let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
433-
.lossy(false)
434-
.finish(appender);
435-
reload_handle
436-
.modify(|layer| {
437-
*layer = Some(fmt::layer().with_ansi(false).with_writer(writer).boxed());
438-
})
439-
.map_err(|e| MainError::Other(format!("installing on-volume log layer: {e}")))?;
440-
// The release: reload the file layer back off, then drop the guard
441-
// to flush and close the on-volume file.
442-
Ok(minimald::server::VolumeLogRelease(Box::new(move || {
443-
let _ = reload_handle.modify(|layer| *layer = None);
444-
drop(guard);
445-
})))
446-
});
447-
return Ok((None, Some(activator)));
448-
}
449-
450-
let detached = std::env::var_os(DETACHED_ENV).is_some();
451-
if !detached {
452-
tracing_subscriber::registry()
453-
.with(fmt::layer().with_writer(ot::StdoutWriter::new))
454-
.with(filter)
455-
.init();
456-
return Ok((None, None));
457-
}
458-
459-
// Under `<state_dir>/logs/` so `<state_dir>` itself stays
460-
// dominated by the sockets, sessions, and providers it already
461-
// owns. `create_dir_all` is idempotent — subsequent daemon
462-
// starts don't churn.
463-
let log_dir = cli
464-
.minimal_state_dir()
465-
.as_utf8_path()
466-
.as_std_path()
467-
.join("logs");
468-
std::fs::create_dir_all(&log_dir)
469-
.map_err(|e| MainError::IO(e, "creating minimald log directory"))?;
470-
let appender = build_log_appender(&log_dir)?;
471-
// lossy(false): dropped records defeat the whole point of a diagnostic
472-
// log, and the native daemon's log volume is nowhere near the bound.
473-
let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
474-
.lossy(false)
475-
.finish(appender);
476-
tracing_subscriber::registry()
477-
// ANSI colors only make sense on a terminal; a file logger
478-
// just gets noise from the escape sequences.
479-
.with(fmt::layer().with_ansi(false).with_writer(writer))
480-
.with(filter)
481-
.init();
482-
tracing::info!(
483-
log_dir = %log_dir.display(),
484-
"detached minimald: routing tracing output to daily-rotated log file",
485-
);
486-
Ok((Some(guard), None))
487-
}
488-
489352
async fn async_main() -> Result<(), MainError> {
490353
// With `networking-proxy` on, both the `ring` (workspace rustls) and the
491354
// `aws-lc-rs` (google-cloud) providers are compiled in, so rustls cannot
@@ -535,16 +398,18 @@ async fn async_main() -> Result<(), MainError> {
535398
return Ok(());
536399
}
537400

538-
// Initialize tracing. Foreground runs (or the parent-side of a
539-
// `--detach` re-exec) log to stdout. A child spawned by
540-
// `spawn_detached` has its stdio null'd — detectable via the
541-
// `MINIMALD_DETACHED` env var — so it routes tracing to a rotated log
542-
// file under the state directory instead; the microVM pid-1 logs to both
543-
// console and a file layer wired up below once the state volume mounts.
544-
// `_log_guard` is bound at function scope so the non-blocking appender's
545-
// worker survives for the daemon's entire lifetime; dropping it
546-
// would flush and terminate the appender prematurely.
547-
let (_log_guard, log_activator) = init_tracing(&cli)?;
401+
// Install tracing. A foreground run logs to stdout only. A detached
402+
// native daemon (stdio null'd, marked by `MINIMALD_DETACHED`) and the
403+
// microVM pid-1 both log to a daily-rotated file, wired up by
404+
// `logger.activate` once the log directory is final (below): immediately
405+
// for the native daemon, after the state volume mounts for the microVM.
406+
// The activation yields a release the server state runs at shutdown.
407+
let log_mode = if is_minimal_microvm() || std::env::var_os(DETACHED_ENV).is_some() {
408+
LogMode::File
409+
} else {
410+
LogMode::Console
411+
};
412+
let logger = DaemonLogger::install(log_mode)?;
548413

549414
let listen_args = cli.listen_args().unwrap();
550415

@@ -638,35 +503,38 @@ async fn async_main() -> Result<(), MainError> {
638503
return Err(MainError::IO(e, "creating minimal dir"));
639504
}
640505

641-
// With the state volume mounted and state relocated onto it, point the
642-
// microVM's reloadable log layer at `<state>/logs` so the in-VM daemon's
643-
// runtime logs land on the persistent volume where `min bug`'s guest
644-
// collector reads them. The resulting release is handed to the server
645-
// state and run by the Shutdown RPC before the quiesce: the appender's
646-
// write-open fd would otherwise keep the volume busy and defeat the clean
647-
// unmount, leaving a dirty ext4 journal on every stop. A failure here must
648-
// not wedge pid-1, so fall back to console-only.
649-
let mut volume_log_release: Option<minimald::server::VolumeLogRelease> = None;
650-
if let Some(activate) = log_activator {
651-
let log_dir = cli
652-
.minimal_state_dir()
653-
.as_utf8_path()
654-
.as_std_path()
655-
.join("logs");
656-
match activate(&log_dir) {
657-
Ok(release) => {
658-
volume_log_release = Some(release);
506+
// The log directory is now final under `<state>/logs` — the native
507+
// daemon's from the start, the microVM's now that the state volume is
508+
// mounted and state relocated onto it, where `min bug`'s guest collector
509+
// reads it. Point the file log at it; the release is handed to the server
510+
// state and run at shutdown (in the microVM, before the quiesce — the
511+
// appender's write-open fd would otherwise hold the volume busy and
512+
// defeat the clean unmount). A foreground run's logger has no file and
513+
// yields `None`. A failure here must not wedge the daemon (pid-1 in the
514+
// microVM), so fall back to console-only.
515+
let log_dir = cli
516+
.minimal_state_dir()
517+
.as_utf8_path()
518+
.as_std_path()
519+
.join("logs");
520+
let log_release = match logger.activate(&log_dir) {
521+
Ok(release) => {
522+
if release.is_some() {
659523
tracing::info!(
660524
log_dir = %log_dir.display(),
661-
"microVM minimald: routing tracing output to daily-rotated log file on the data volume",
525+
"routing tracing output to daily-rotated log file",
662526
);
663527
}
664-
Err(e) => tracing::warn!(
528+
release
529+
}
530+
Err(e) => {
531+
tracing::warn!(
665532
error = ?e,
666-
"microVM minimald: could not open the on-volume log file; continuing with console logging only",
667-
),
533+
"could not open the daemon log file; continuing with console logging only",
534+
);
535+
None
668536
}
669-
}
537+
};
670538

671539
// The host-key path lives under the instance dir; ensure it exists for
672540
// both the UDS and vsock paths.
@@ -834,9 +702,7 @@ async fn async_main() -> Result<(), MainError> {
834702
);
835703
// TODO: When we have a daemonize command, daemonize here.
836704

837-
// A UDS (native/DM2) daemon is never the microVM pid-1, so there is no
838-
// on-volume log to release.
839-
Server::run(config, listener, None)
705+
Server::run(config, listener, log_release)
840706
.await
841707
.map_err(|e| MainError::IO(e, "serving on UDS"))
842708
} else {
@@ -867,7 +733,7 @@ async fn async_main() -> Result<(), MainError> {
867733
}
868734
};
869735

870-
Server::run(config, listener, volume_log_release)
736+
Server::run(config, listener, log_release)
871737
.await
872738
.map_err(|e| MainError::IO(e, "serving on guest vsock"))
873739
}

0 commit comments

Comments
 (0)