mod log_rotate;
mod probe;
mod status_json;
mod tick;
mod types;
pub(crate) use probe::daemon_is_live;
pub(crate) use types::{SwitchBackoff, switch_backoff_ms};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use crate::claude::{
LinkState, classify_credentials_link, is_first_login, link_profile_credentials,
};
use crate::lockorder::RankedMutex;
use crate::logline::logline;
use crate::profile::{
AppConfig, ConfigHandle, app_state_mtime, atomic_write_600, clauth_dir, load_config, mkdir_700,
};
use crate::usage::{
ActivityStore, FetchStatus, LastFetchedAt, NextRefreshPerProfile, PendingSwitch,
PendingSwitchOff, RateLimitStreaks, RefetchQueue, StatusStore, SuppressedGenericStore,
ThirdPartyList, ThirdPartyStatusStore, ThirdPartyUsageStore, TokenList, UsageStore,
bootstrap_fetch, bootstrap_third_party, collect_third_party_entries, collect_tokens,
spawn_refresher,
};
use status_json::{LiveSignals, build_status};
const TICK: Duration = Duration::from_secs(1);
const LOG_ROTATE_EVERY_TICKS: u64 = 300;
const STATUS_FILE: &str = "status.json";
const LOCK_FILE: &str = "clauthd.lock";
const WATCHDOG_DEADLINE: Duration = Duration::from_secs(60);
const WATCHDOG_POLL: Duration = Duration::from_secs(10);
fn watchdog_check(last_tick_ms: u64, now_ms: u64, deadline_ms: u64, on_stall: impl FnOnce()) {
if last_tick_ms != 0 && now_ms.saturating_sub(last_tick_ms) > deadline_ms {
on_stall();
}
}
#[cfg(unix)]
fn migrate_clauth_perms_700(dir: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
for d in [dir.to_path_buf(), dir.join("profiles")] {
if d.is_dir() {
let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
}
}
let log = dir.join("daemon.log");
if log.is_file() {
let _ = std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o600));
}
}
#[cfg(not(unix))]
fn migrate_clauth_perms_700(_dir: &std::path::Path) {}
pub(crate) fn serve() -> Result<()> {
crate::logline::enable_timestamps();
crate::platform::init();
crate::runtime::gc_stale_runtimes();
let dir = clauth_dir()?;
mkdir_700(&dir).context("failed to create ~/.clauth")?;
migrate_clauth_perms_700(&dir);
let lock_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(dir.join(LOCK_FILE))
.context("failed to open the clauth daemon lock file")?;
if lock_file.try_lock().is_err() {
logline!("clauth daemon: another instance holds the lock — standing by until it exits");
lock_file
.lock()
.context("failed to acquire the clauth daemon lock")?;
}
let _lock = lock_file;
let config = load_config()?;
let mut daemon = Daemon::new(config, dir.join(STATUS_FILE));
daemon.boot();
logline!(
"clauth daemon: running (status → {})",
daemon.status_path.display()
);
daemon.run();
Ok(())
}
pub(crate) fn status_oneshot() -> Result<()> {
let config = load_config()?;
let interval = config.state.refresh_interval_ms;
let body = build_status(&config, interval, None);
println!("{}", serde_json::to_string_pretty(&body)?);
Ok(())
}
fn active_diverged_unsaved(active: &str) -> bool {
matches!(
classify_credentials_link(active).ok(),
Some(LinkState::Diverged)
) && !is_first_login(active).unwrap_or(false)
}
struct Daemon {
config: ConfigHandle,
usage_tokens: TokenList,
usage_store: UsageStore,
usage_status: StatusStore,
refresh_interval: Arc<AtomicU64>,
next_refresh_per_profile: NextRefreshPerProfile,
activity: ActivityStore,
last_fetched: LastFetchedAt,
rate_limit_streaks: RateLimitStreaks,
pending_switch: PendingSwitch,
pending_switch_off: PendingSwitchOff,
refetch_queue: RefetchQueue,
third_party_tokens: ThirdPartyList,
third_party_usage_store: ThirdPartyUsageStore,
third_party_status: ThirdPartyStatusStore,
shutting_down: Arc<AtomicBool>,
last_state_mtime: Option<SystemTime>,
heartbeat: Arc<AtomicU64>,
switch_backoff: Option<SwitchBackoff>,
switch_failure_logs: u64,
status_path: PathBuf,
}
impl Daemon {
fn new(config: AppConfig, status_path: PathBuf) -> Self {
let usage_tokens: TokenList = Arc::new(RankedMutex::new(collect_tokens(&config)));
let third_party_tokens: ThirdPartyList = Arc::new(RankedMutex::new(
collect_third_party_entries(&config.profiles),
));
let refresh_interval = Arc::new(AtomicU64::new(config.state.refresh_interval_ms));
Self {
config: Arc::new(RankedMutex::new(config)),
usage_tokens,
usage_store: Arc::new(RankedMutex::new(HashMap::new())),
usage_status: Arc::new(RankedMutex::new(HashMap::new())),
refresh_interval,
next_refresh_per_profile: Arc::new(RankedMutex::new(HashMap::new())),
activity: Arc::new(RankedMutex::new(HashMap::new())),
last_fetched: Arc::new(RankedMutex::new(HashMap::new())),
rate_limit_streaks: Arc::new(RankedMutex::new(HashMap::new())),
pending_switch: Arc::new(RankedMutex::new(HashSet::new())),
pending_switch_off: Arc::new(RankedMutex::new(false)),
refetch_queue: Arc::new(RankedMutex::new(HashSet::new())),
third_party_tokens,
third_party_usage_store: Arc::new(RankedMutex::new(HashMap::new())),
third_party_status: Arc::new(RankedMutex::new(HashMap::new())),
shutting_down: Arc::new(AtomicBool::new(false)),
last_state_mtime: app_state_mtime(),
heartbeat: Arc::new(AtomicU64::new(0)),
switch_backoff: None,
switch_failure_logs: 0,
status_path,
}
}
fn boot(&self) {
#[allow(
clippy::expect_used,
reason = "config mutex poisoning is unrecoverable"
)]
let active = self
.config
.lock()
.expect("config mutex poisoned")
.state
.active_profile
.as_deref()
.map(str::to_string);
if let Some(active) = active {
let _ = link_profile_credentials(&active);
}
let (snapshot, third_party) = {
#[allow(
clippy::expect_used,
reason = "config mutex poisoning is unrecoverable"
)]
let cfg = self.config.lock().expect("config mutex poisoned");
(
collect_tokens(&cfg),
collect_third_party_entries(&cfg.profiles),
)
};
let interval = self.refresh_interval.load(Ordering::Relaxed);
bootstrap_fetch(
&self.usage_store,
&self.usage_status,
&self.last_fetched,
&snapshot,
interval,
);
bootstrap_third_party(
&self.third_party_usage_store,
&self.third_party_status,
&self.last_fetched,
&third_party,
interval,
);
self.spawn_scheduler();
}
fn spawn_scheduler(&self) {
let suppressed: SuppressedGenericStore = Arc::new(RankedMutex::new(HashSet::new()));
spawn_refresher(
Arc::clone(&self.config),
Arc::clone(&self.usage_tokens),
Arc::clone(&self.usage_store),
Arc::clone(&self.usage_status),
Arc::clone(&self.refresh_interval),
Arc::clone(&self.next_refresh_per_profile),
Arc::clone(&self.activity),
Arc::clone(&self.last_fetched),
Arc::clone(&self.rate_limit_streaks),
Arc::clone(&self.pending_switch),
Arc::clone(&self.pending_switch_off),
Arc::clone(&self.refetch_queue),
Arc::clone(&self.third_party_tokens),
Arc::clone(&self.third_party_usage_store),
Arc::clone(&self.third_party_status),
suppressed,
Arc::clone(&self.shutting_down),
false,
);
}
fn run(&mut self) {
self.write_status();
self.heartbeat
.store(crate::usage::now_ms(), Ordering::Relaxed);
self.spawn_watchdog();
let log_path = self.status_path.with_file_name("daemon.log");
let mut ticks: u64 = 0;
loop {
if ticks.is_multiple_of(LOG_ROTATE_EVERY_TICKS) {
let _ = log_rotate::rotate_log_if_large(
&log_path,
log_rotate::LOG_MAX_BYTES,
log_rotate::LOG_KEEP_BYTES,
);
}
std::thread::sleep(TICK);
self.tick();
self.heartbeat
.store(crate::usage::now_ms(), Ordering::Relaxed);
ticks = ticks.wrapping_add(1);
}
}
fn spawn_watchdog(&self) {
let heartbeat = Arc::clone(&self.heartbeat);
let spawned = std::thread::Builder::new()
.name("clauth-daemon-watchdog".into())
.spawn(move || {
loop {
std::thread::sleep(WATCHDOG_POLL);
watchdog_check(
heartbeat.load(Ordering::Relaxed),
crate::usage::now_ms(),
WATCHDOG_DEADLINE.as_millis() as u64,
|| {
logline!(
"clauth daemon: watchdog — no tick within {}s; aborting for a \
clean launchd restart",
WATCHDOG_DEADLINE.as_secs()
);
std::process::abort();
},
);
}
});
if let Err(e) = spawned {
logline!(
"clauth daemon: failed to spawn the anti-wedge watchdog: {e} — \
a stalled tick will NOT auto-restart this process"
);
}
}
fn write_status(&self) {
let interval = self.refresh_interval.load(Ordering::Relaxed);
let status_snap: HashMap<String, FetchStatus> = self
.usage_status
.lock()
.map(|m| m.clone())
.unwrap_or_default();
let next_snap: HashMap<String, u64> = self
.next_refresh_per_profile
.lock()
.map(|m| m.clone())
.unwrap_or_default();
let streaks_snap: HashMap<String, u32> = self
.rate_limit_streaks
.lock()
.map(|m| m.clone())
.unwrap_or_default();
let pending_snap: Option<String> = self
.pending_switch
.lock()
.ok()
.and_then(|q| q.iter().min().cloned());
let live = LiveSignals {
status: &status_snap,
next_refresh: &next_snap,
streaks: &streaks_snap,
pending_switch: pending_snap.as_deref(),
};
let body = {
#[allow(
clippy::expect_used,
reason = "config mutex poisoning is unrecoverable"
)]
let cfg = self.config.lock().expect("config poisoned");
build_status(&cfg, interval, Some(&live))
};
match serde_json::to_vec_pretty(&body) {
Ok(json) => {
if let Err(e) = atomic_write_600(&self.status_path, &json) {
logline!("clauth daemon: failed to write status.json: {e}");
}
}
Err(e) => logline!("clauth daemon: failed to serialize status.json: {e}"),
}
}
}
#[cfg(test)]
#[path = "../../tests/inline/daemon_watchdog.rs"]
mod watchdog_tests;
#[cfg(test)]
#[path = "../../tests/inline/daemon_mod.rs"]
mod tests;