use std::path::PathBuf;
use std::time::Duration;
use crate::__bypass::RawAccessExt as _;
use crate::context::{DjogiContext, PinnedCtx};
use crate::error::{DbError, DjogiError};
use crate::live_migrate::backfill::BackfillError;
use crate::pg::pool::DjogiPool;
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub poll_interval: Duration,
pub claim_stale_after: Duration,
pub allow_non_localhost: bool,
pub database_url: String,
pub host: String,
pub pid: i64,
pub profile: String,
pub workspace_root: PathBuf,
}
impl DaemonConfig {
pub fn default_for_localhost(database_url: impl Into<String>) -> Self {
Self {
poll_interval: Duration::from_secs(30),
claim_stale_after: Duration::from_secs(10 * 60),
allow_non_localhost: false,
database_url: database_url.into(),
host: hostname_or_unknown(),
pid: i64::from(std::process::id()),
profile: "development".to_string(),
workspace_root: std::path::PathBuf::from("."),
}
}
}
fn hostname_or_unknown() -> String {
std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string())
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DaemonError {
#[error(
"daemon refused to start: not running on localhost \
(--allow-non-localhost not passed)"
)]
NotLocalhost,
#[error("daemon refused to start: DJOGI_ENV=production")]
Production,
#[error(transparent)]
Backfill(#[from] BackfillError),
#[error(transparent)]
Database(#[from] DjogiError),
#[error("daemon shutdown signal received")]
Shutdown,
}
impl From<DbError> for DaemonError {
fn from(value: DbError) -> Self {
DaemonError::Database(DjogiError::Db(value))
}
}
const CANDIDATE_QUERY_SQL: &str = "\
SELECT plan_id, target_database, app_label, slug, current_step \
FROM djogi_live_plans \
WHERE status = 'running' \
AND current_step IN ('backfill_chunked', 'validate_backfill') \
AND ( \
claimed_by_pid = $2 \
OR ( claimed_by_pid IS NULL \
AND ( last_progress_at IS NULL \
OR last_progress_at < now() - (INTERVAL '1 second' * $1) ) ) \
)";
const CLAIM_UPDATE_SQL: &str = "\
UPDATE djogi_live_plans \
SET claimed_by_pid = $4, claimed_by_host = $5, claimed_at = now() \
WHERE target_database = $1 AND app_label = $2 AND plan_id = $3 \
AND status = 'running' \
AND current_step IN ('backfill_chunked', 'validate_backfill')";
const CLEAR_CLAIM_SQL: &str = "\
UPDATE djogi_live_plans \
SET claimed_by_pid = NULL, claimed_by_host = NULL, claimed_at = NULL \
WHERE target_database = $1 AND app_label = $2 AND plan_id = $3";
pub async fn run_daemon(ctx: &mut DjogiContext, config: DaemonConfig) -> Result<(), DaemonError> {
enforce_environment_gates(&config)?;
tracing::info!(
host = %config.host,
pid = config.pid,
poll_interval_secs = config.poll_interval.as_secs(),
claim_stale_after_secs = config.claim_stale_after.as_secs(),
"live-migrate daemon started",
);
#[cfg(unix)]
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| {
DaemonError::Database(DjogiError::Db(DbError::other(format!(
"register SIGTERM handler: {e}"
))))
})?;
loop {
#[cfg(unix)]
{
tokio::select! {
_ = tokio::signal::ctrl_c() => {
tracing::info!("live-migrate daemon: SIGINT received");
return Err(DaemonError::Shutdown);
}
_ = sigterm.recv() => {
tracing::info!("live-migrate daemon: SIGTERM received");
return Err(DaemonError::Shutdown);
}
_ = tokio::time::sleep(config.poll_interval) => {
if let Err(e) = poll_once(ctx, &config).await {
tracing::warn!(
error = %e,
"live-migrate daemon: poll iteration failed; \
continuing to next interval",
);
}
}
}
}
#[cfg(not(unix))]
{
tokio::select! {
_ = tokio::signal::ctrl_c() => {
tracing::info!("live-migrate daemon: shutdown signal received");
return Err(DaemonError::Shutdown);
}
_ = tokio::time::sleep(config.poll_interval) => {
if let Err(e) = poll_once(ctx, &config).await {
tracing::warn!(
error = %e,
"live-migrate daemon: poll iteration failed; \
continuing to next interval",
);
}
}
}
}
}
}
fn enforce_environment_gates(config: &DaemonConfig) -> Result<(), DaemonError> {
if production_env_set() {
return Err(DaemonError::Production);
}
if config.profile == "production" {
return Err(DaemonError::Production);
}
if !config.allow_non_localhost && !crate::migrate::is_localhost_connection(&config.database_url)
{
return Err(DaemonError::NotLocalhost);
}
Ok(())
}
fn production_env_set() -> bool {
match std::env::var("DJOGI_ENV") {
Ok(v) => v.eq_ignore_ascii_case("production"),
Err(_) => false,
}
}
async fn poll_once(ctx: &mut DjogiContext, config: &DaemonConfig) -> Result<(), DaemonError> {
let candidates = read_candidates(ctx, config.claim_stale_after, config.pid).await?;
if candidates.is_empty() {
return Ok(());
}
let pool = ctx.share_pool().ok_or_else(|| {
DaemonError::Database(DjogiError::Db(DbError::other(
"daemon poll requires a pool-backed DjogiContext (built via \
DjogiContext::from_pool); cannot resume backfills without a pool \
to open per-chunk transactions on",
)))
})?;
tracing::debug!(
count = candidates.len(),
"live-migrate daemon: candidate plans this iteration",
);
for candidate in candidates {
if let Err(e) = drive_candidate(ctx, &pool, config, &candidate).await {
tracing::warn!(
plan_id = candidate.plan_id,
target_database = %candidate.target_database,
app_label = %candidate.app_label,
error = %e,
"live-migrate daemon: candidate skipped",
);
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DaemonCandidate {
plan_id: i64,
target_database: String,
app_label: String,
slug: String,
current_step: String,
}
async fn read_candidates(
ctx: &mut DjogiContext,
claim_stale_after: Duration,
pid: i64,
) -> Result<Vec<DaemonCandidate>, DaemonError> {
let stale_secs: i64 = i64::try_from(claim_stale_after.as_secs()).unwrap_or(i64::MAX);
let rows = ctx
.raw_rows(CANDIDATE_QUERY_SQL, &[&stale_secs, &pid])
.await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let plan_id: i64 = row.try_get(0).map_err(|e| {
DjogiError::Db(DbError::other(format!("candidate row decode plan_id: {e}")))
})?;
let target_database: String = row.try_get(1).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"candidate row decode target_database: {e}"
)))
})?;
let app_label: String = row.try_get(2).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"candidate row decode app_label: {e}"
)))
})?;
let slug: String = row.try_get(3).map_err(|e| {
DjogiError::Db(DbError::other(format!("candidate row decode slug: {e}")))
})?;
let current_step: Option<String> = row.try_get(4).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"candidate row decode current_step: {e}"
)))
})?;
let Some(current_step) = current_step else {
continue;
};
out.push(DaemonCandidate {
plan_id,
target_database,
app_label,
slug,
current_step,
});
}
Ok(out)
}
async fn drive_candidate(
ctx: &mut DjogiContext,
pool: &DjogiPool,
config: &DaemonConfig,
candidate: &DaemonCandidate,
) -> Result<(), DaemonError> {
let mut pinned = ctx.pin_for_migration().await?;
let lock_key = candidate.plan_id;
if !try_acquire_advisory_lock(&mut pinned, lock_key).await? {
tracing::debug!(
plan_id = candidate.plan_id,
"live-migrate daemon: advisory lock held by another holder; skipping",
);
return Ok(());
}
let result = drive_under_lock(&mut pinned, pool, config, candidate).await;
release_advisory_lock(&mut pinned, lock_key).await;
if result.is_ok() {
pinned.mark_clean();
}
result
}
async fn drive_under_lock(
ctx: &mut PinnedCtx<'_>,
pool: &DjogiPool,
config: &DaemonConfig,
candidate: &DaemonCandidate,
) -> Result<(), DaemonError> {
if !record_claim(ctx, config, candidate).await? {
tracing::debug!(
plan_id = candidate.plan_id,
"live-migrate daemon: claim pre-empted between SELECT and UPDATE; skipping",
);
return Ok(());
}
let outcome = match candidate.current_step.as_str() {
"backfill_chunked" => resume_backfill_for_candidate(pool, candidate, config).await,
"validate_backfill" => {
tracing::debug!(
plan_id = candidate.plan_id,
"live-migrate daemon: validate_backfill is operator-only; \
daemon does not advance past the gate",
);
Ok(())
}
other => {
tracing::debug!(
plan_id = candidate.plan_id,
step = %other,
"live-migrate daemon: step changed between SELECT and claim; skipping",
);
Ok(())
}
};
if let Err(e) = clear_claim(ctx, candidate).await {
tracing::warn!(
plan_id = candidate.plan_id,
error = %e,
"live-migrate daemon: clear_claim failed; columns will reset on next claim",
);
}
outcome
}
async fn resume_backfill_for_candidate(
pool: &DjogiPool,
candidate: &DaemonCandidate,
daemon_cfg: &DaemonConfig,
) -> Result<(), DaemonError> {
use crate::live_migrate::{
backfill::resume_backfill, compose::extract_backfill_params, plan_file::read_plan,
};
use crate::types::HeerId;
let plan_id = HeerId::from_i64(candidate.plan_id).map_err(|e| {
DaemonError::Database(DjogiError::Db(DbError::other(format!(
"invalid plan_id {}: {}",
candidate.plan_id, e
))))
})?;
let slug = &candidate.slug;
let path = crate::live_migrate::plan_file::plan_path(
&daemon_cfg.workspace_root.join("migrations"),
&candidate.target_database,
plan_id,
slug,
);
let plan = read_plan(&path).map_err(|e| {
DaemonError::Database(DjogiError::Db(DbError::other(format!(
"failed to read plan file {}: {}",
path.display(),
e
))))
})?;
let extract_result = extract_backfill_params(&plan.steps);
let (table, predicate_template, chunk_size) = match extract_result {
super::compose::ExtractResult::Params {
table,
filter,
batch_size,
..
} => (table, filter, batch_size as u32),
super::compose::ExtractResult::NotBackfillChunked => {
return Err(DaemonError::Database(DjogiError::Db(DbError::other(
"plan has no BackfillChunked step to resume",
))));
}
super::compose::ExtractResult::Malformed(reason) => {
return Err(DaemonError::Database(DjogiError::Db(DbError::other(
format!("malformed backfill step: {}", reason),
))));
}
};
let mut backfill_ctx = DjogiContext::from_pool(pool.clone());
let result = resume_backfill(
&mut backfill_ctx,
plan_id,
&table,
&predicate_template,
chunk_size,
true, )
.await;
match result {
Ok(chunks) => {
tracing::info!(
plan_id = %plan_id,
target_database = %candidate.target_database,
app_label = %candidate.app_label,
chunks_processed = chunks.len(),
"live-migrate daemon: backfill completed successfully",
);
Ok(())
}
Err(e) => {
let record_outcome = crate::live_migrate::state::record_failure(
&mut backfill_ctx,
plan_id,
&candidate.target_database,
&candidate.app_label,
format!("daemon backfill resume failed: {e}"),
true, )
.await;
if let Err(record_err) = record_outcome {
tracing::warn!(
plan_id = %plan_id,
error = %record_err,
"live-migrate daemon: failed to record backfill failure on row",
);
}
Err(match e {
BackfillError::Database(db_err) => DaemonError::Database(db_err),
other => DaemonError::Backfill(other),
})
}
}
}
async fn try_acquire_advisory_lock(
ctx: &mut PinnedCtx<'_>,
lock_key: i64,
) -> Result<bool, DaemonError> {
assert!(
!ctx.is_pool_backed(),
"daemon advisory lock called on a pool-backed context — \
the lock would be acquired on an arbitrary pool connection \
and subsequent operations would run on different connections. \
Callers must use ctx.pin_for_migration() first (GH #274 / #331).",
);
let row = ctx
.raw_rows("SELECT pg_try_advisory_lock($1)", &[&lock_key])
.await?;
let Some(first) = row.first() else {
return Err(DaemonError::Database(DjogiError::Db(DbError::other(
"pg_try_advisory_lock returned no rows",
))));
};
let acquired: bool = first
.try_get(0)
.map_err(|e| DjogiError::Db(DbError::other(format!("pg_try_advisory_lock decode: {e}"))))?;
Ok(acquired)
}
async fn release_advisory_lock(ctx: &mut PinnedCtx<'_>, lock_key: i64) {
assert!(
!ctx.is_pool_backed(),
"daemon advisory unlock called on a pool-backed context — \
session-pinning correctness failure (GH #274 / #331).",
);
if let Err(e) = ctx
.raw_execute("SELECT pg_advisory_unlock($1)", &[&lock_key])
.await
{
tracing::warn!(
error = %e,
lock_key,
"live-migrate daemon: pg_advisory_unlock failed; \
lock will release on session close",
);
}
}
async fn record_claim(
ctx: &mut DjogiContext,
config: &DaemonConfig,
candidate: &DaemonCandidate,
) -> Result<bool, DaemonError> {
let affected = ctx
.raw_execute(
CLAIM_UPDATE_SQL,
&[
&candidate.target_database,
&candidate.app_label,
&candidate.plan_id,
&config.pid,
&config.host,
],
)
.await?;
Ok(affected == 1)
}
async fn clear_claim(
ctx: &mut DjogiContext,
candidate: &DaemonCandidate,
) -> Result<(), DaemonError> {
ctx.raw_execute(
CLEAR_CLAIM_SQL,
&[
&candidate.target_database,
&candidate.app_label,
&candidate.plan_id,
],
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config(database_url: &str) -> DaemonConfig {
DaemonConfig {
poll_interval: Duration::from_millis(50),
claim_stale_after: Duration::from_secs(60),
allow_non_localhost: false,
database_url: database_url.to_string(),
host: "test-host".to_string(),
pid: 12345,
profile: "development".to_string(),
workspace_root: std::path::PathBuf::from("."),
}
}
#[test]
fn default_for_localhost_uses_30s_poll_interval() {
let cfg = DaemonConfig::default_for_localhost("postgres://localhost/x");
assert_eq!(cfg.poll_interval, Duration::from_secs(30));
}
#[test]
fn default_for_localhost_uses_10min_stale_threshold() {
let cfg = DaemonConfig::default_for_localhost("postgres://localhost/x");
assert_eq!(cfg.claim_stale_after, Duration::from_secs(600));
}
#[test]
fn default_for_localhost_refuses_remote_connections() {
let cfg = DaemonConfig::default_for_localhost("postgres://localhost/x");
assert!(
!cfg.allow_non_localhost,
"default config must require localhost",
);
}
#[test]
fn default_for_localhost_records_running_pid() {
let cfg = DaemonConfig::default_for_localhost("postgres://localhost/x");
assert_eq!(cfg.pid, i64::from(std::process::id()));
assert!(!cfg.host.is_empty(), "host must be a non-empty diagnostic");
}
#[test]
fn default_for_localhost_records_supplied_url() {
let cfg = DaemonConfig::default_for_localhost("postgres://127.0.0.1/main");
assert_eq!(cfg.database_url, "postgres://127.0.0.1/main");
}
#[test]
fn default_for_localhost_uses_development_profile() {
let cfg = DaemonConfig::default_for_localhost("postgres://localhost/x");
assert_eq!(cfg.profile, "development");
}
#[test]
fn candidate_query_filters_on_running_status() {
assert!(
CANDIDATE_QUERY_SQL.contains("status = 'running'"),
"candidate query must restrict to running plans: {CANDIDATE_QUERY_SQL}",
);
}
#[test]
fn candidate_query_filters_on_auto_resumable_steps_only() {
assert!(
CANDIDATE_QUERY_SQL.contains("'backfill_chunked'"),
"candidate query must include backfill_chunked",
);
assert!(
CANDIDATE_QUERY_SQL.contains("'validate_backfill'"),
"candidate query must include validate_backfill",
);
assert!(
!CANDIDATE_QUERY_SQL.contains("'cutover_reads'"),
"candidate query must not pick up cutover_reads",
);
assert!(
!CANDIDATE_QUERY_SQL.contains("'cutover_writes'"),
"candidate query must not pick up cutover_writes",
);
assert!(
!CANDIDATE_QUERY_SQL.contains("'finalize_constraints'"),
"candidate query must not pick up finalize_constraints",
);
}
#[test]
fn candidate_query_excludes_paused_plans() {
assert!(
!CANDIDATE_QUERY_SQL.contains("'paused'"),
"candidate query must not consider paused plans",
);
}
#[test]
fn candidate_query_uses_stale_threshold_parameter() {
assert!(
CANDIDATE_QUERY_SQL.contains("INTERVAL '1 second' * $1"),
"candidate query must bind the stale threshold via $1: {CANDIDATE_QUERY_SQL}",
);
}
#[test]
fn candidate_query_recognises_unclaimed_rows() {
assert!(
CANDIDATE_QUERY_SQL.contains("claimed_by_pid IS NULL"),
"candidate query must consider rows without a recorded claim",
);
}
#[test]
fn candidate_query_self_pid_is_or_escape_hatch() {
assert!(
CANDIDATE_QUERY_SQL.contains("claimed_by_pid = $2"),
"self-pid escape hatch must be present: {CANDIDATE_QUERY_SQL}",
);
assert!(
CANDIDATE_QUERY_SQL.contains("claimed_by_pid IS NULL"),
"unclaimed branch must be guarded by claimed_by_pid IS NULL: {CANDIDATE_QUERY_SQL}",
);
assert!(
CANDIDATE_QUERY_SQL.contains("last_progress_at IS NULL"),
"stale branch must accept never-progressed rows: {CANDIDATE_QUERY_SQL}",
);
assert!(
CANDIDATE_QUERY_SQL.contains("last_progress_at < now() - (INTERVAL '1 second' * $1)"),
"stale branch must use `<` against now()-INTERVAL: {CANDIDATE_QUERY_SQL}",
);
}
#[test]
fn candidate_query_binds_pid_as_parameter_two() {
assert!(
CANDIDATE_QUERY_SQL.contains("claimed_by_pid = $2"),
"PID must bind as `$2`: {CANDIDATE_QUERY_SQL}",
);
}
#[test]
fn claim_update_re_asserts_status_running() {
assert!(
CLAIM_UPDATE_SQL.contains("status = 'running'"),
"claim UPDATE must re-assert status = 'running': {CLAIM_UPDATE_SQL}",
);
}
#[test]
fn claim_update_re_asserts_auto_resumable_steps() {
assert!(
CLAIM_UPDATE_SQL.contains("'backfill_chunked'"),
"claim UPDATE must re-assert backfill_chunked",
);
assert!(
CLAIM_UPDATE_SQL.contains("'validate_backfill'"),
"claim UPDATE must re-assert validate_backfill",
);
}
#[cfg(unix)]
#[test]
fn sigterm_handler_registration_succeeds_on_unix() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime build");
rt.block_on(async {
let result = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate());
assert!(
result.is_ok(),
"tokio signal feature must be enabled for SIGTERM handler",
);
});
}
#[test]
fn enforce_gates_accepts_localhost_when_env_unset() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::remove_var("DJOGI_ENV") };
let cfg = test_config("postgres://localhost/main");
assert!(enforce_environment_gates(&cfg).is_ok());
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn enforce_gates_refuses_production_env() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::set_var("DJOGI_ENV", "production") };
let cfg = test_config("postgres://localhost/main");
let err = enforce_environment_gates(&cfg).unwrap_err();
assert!(matches!(err, DaemonError::Production));
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn enforce_gates_refuses_remote_url_without_override() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::remove_var("DJOGI_ENV") };
let cfg = test_config("postgres://prod.example.com:5432/main");
let err = enforce_environment_gates(&cfg).unwrap_err();
assert!(matches!(err, DaemonError::NotLocalhost));
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn enforce_gates_accepts_remote_url_with_override() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::remove_var("DJOGI_ENV") };
let mut cfg = test_config("postgres://prod.example.com:5432/main");
cfg.allow_non_localhost = true;
assert!(enforce_environment_gates(&cfg).is_ok());
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn enforce_gates_refuses_production_profile() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::remove_var("DJOGI_ENV") };
let mut cfg = test_config("postgres://localhost/main");
cfg.profile = "production".to_string();
let err = enforce_environment_gates(&cfg).unwrap_err();
assert!(matches!(err, DaemonError::Production));
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn enforce_gates_accepts_non_production_profile_strings() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::remove_var("DJOGI_ENV") };
for profile in [
"development",
"staging",
"test",
"Production",
"PROD",
"prod",
] {
let mut cfg = test_config("postgres://localhost/main");
cfg.profile = profile.to_string();
assert!(
enforce_environment_gates(&cfg).is_ok(),
"profile=`{profile}` must NOT fire the gate (only lowercase `production` does)",
);
}
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn production_env_set_recognises_production() {
let prior = std::env::var("DJOGI_ENV").ok();
unsafe { std::env::set_var("DJOGI_ENV", "production") };
assert!(production_env_set());
unsafe { std::env::set_var("DJOGI_ENV", "PRODUCTION") };
assert!(production_env_set(), "case-insensitive match required");
unsafe { std::env::set_var("DJOGI_ENV", "development") };
assert!(!production_env_set());
unsafe { std::env::remove_var("DJOGI_ENV") };
assert!(!production_env_set());
match prior {
Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
#[test]
fn db_error_converts_into_database_variant() {
let db = DbError::other("boom");
let de: DaemonError = db.into();
assert!(matches!(de, DaemonError::Database(_)));
}
#[test]
fn djogi_error_converts_into_database_variant() {
let je = DjogiError::Db(DbError::other("boom"));
let de: DaemonError = je.into();
assert!(matches!(de, DaemonError::Database(_)));
}
#[test]
fn shutdown_error_renders_human_message() {
let e = DaemonError::Shutdown;
assert!(e.to_string().contains("shutdown signal"));
}
#[test]
fn not_localhost_error_renders_actionable_message() {
let e = DaemonError::NotLocalhost;
let msg = e.to_string();
assert!(msg.contains("localhost"), "{msg}");
assert!(msg.contains("--allow-non-localhost"), "{msg}");
}
#[test]
fn production_error_renders_actionable_message() {
let e = DaemonError::Production;
assert!(e.to_string().contains("DJOGI_ENV"));
}
#[test]
fn hostname_or_unknown_falls_back_when_unset() {
let prior = std::env::var("HOSTNAME").ok();
unsafe { std::env::remove_var("HOSTNAME") };
let h = hostname_or_unknown();
assert_eq!(h, "unknown");
unsafe { std::env::set_var("HOSTNAME", "test-box.example") };
let h = hostname_or_unknown();
assert_eq!(h, "test-box.example");
match prior {
Some(v) => unsafe { std::env::set_var("HOSTNAME", v) },
None => unsafe { std::env::remove_var("HOSTNAME") },
}
}
}