feat(config): support human-readable durations with legacy compatibility
This commit is contained in:
22 files changed
+715
-151
No files matched your search
+221
-51
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use wakey_core::config_duration::{self, LegacyDuration};
|
||||
|
||||
use crate::protocol::{
|
||||
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
@@ -19,19 +21,25 @@ const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
|
||||
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
|
||||
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
|
||||
|
||||
// Derive canonical serde functions once; the trait adapter below translates
|
||||
// legacy keys without maintaining another copy of every config field.
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(remote = "Self")]
|
||||
pub struct AgentConfig {
|
||||
pub server_url: String,
|
||||
pub agent_id: String,
|
||||
pub agent_token: String,
|
||||
#[serde(default = "default_reconnect_base_ms")]
|
||||
pub reconnect_base_ms: u64,
|
||||
#[serde(default = "default_reconnect_max_ms")]
|
||||
pub reconnect_max_ms: u64,
|
||||
#[serde(default = "default_observation_sync_interval_seconds")]
|
||||
pub observation_sync_interval_seconds: u64,
|
||||
#[serde(default = "default_observation_retention_days")]
|
||||
pub observation_retention_days: u64,
|
||||
#[serde(default = "default_reconnect_base", with = "humantime_serde")]
|
||||
pub reconnect_base: Duration,
|
||||
#[serde(default = "default_reconnect_max", with = "humantime_serde")]
|
||||
pub reconnect_max: Duration,
|
||||
#[serde(
|
||||
default = "default_observation_sync_interval",
|
||||
with = "humantime_serde"
|
||||
)]
|
||||
pub observation_sync_interval: Duration,
|
||||
#[serde(default = "default_observation_retention", with = "humantime_serde")]
|
||||
pub observation_retention: Duration,
|
||||
#[serde(default = "default_pid_file")]
|
||||
pub pid_file: PathBuf,
|
||||
#[serde(default = "default_dhcp_leases_path")]
|
||||
@@ -47,7 +55,7 @@ pub struct AgentConfig {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(remote = "Self", deny_unknown_fields)]
|
||||
pub struct TerminalConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
@@ -60,8 +68,8 @@ pub struct TerminalConfig {
|
||||
#[serde(default = "default_terminal_max_sessions")]
|
||||
pub max_sessions: usize,
|
||||
/// Maximum lifetime of a PTY process. Zero disables automatic expiry.
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
#[serde(default = "default_terminal_session_ttl", with = "humantime_serde")]
|
||||
pub session_ttl: Duration,
|
||||
}
|
||||
|
||||
impl Default for TerminalConfig {
|
||||
@@ -72,7 +80,7 @@ impl Default for TerminalConfig {
|
||||
args: Vec::new(),
|
||||
current_dir: None,
|
||||
max_sessions: default_terminal_max_sessions(),
|
||||
session_ttl_seconds: default_terminal_session_ttl_seconds(),
|
||||
session_ttl: default_terminal_session_ttl(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,10 +89,10 @@ pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig
|
||||
server_url: "https://wakey.ldlda.com".to_string(),
|
||||
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
|
||||
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
|
||||
reconnect_base_ms: default_reconnect_base_ms(),
|
||||
reconnect_max_ms: default_reconnect_max_ms(),
|
||||
observation_sync_interval_seconds: default_observation_sync_interval_seconds(),
|
||||
observation_retention_days: default_observation_retention_days(),
|
||||
reconnect_base: default_reconnect_base(),
|
||||
reconnect_max: default_reconnect_max(),
|
||||
observation_sync_interval: default_observation_sync_interval(),
|
||||
observation_retention: default_observation_retention(),
|
||||
pid_file: default_pid_file(),
|
||||
dhcp_leases_path: default_dhcp_leases_path(),
|
||||
mac_name_cache_path: default_mac_name_cache_path(),
|
||||
@@ -99,16 +107,10 @@ impl fmt::Debug for AgentConfig {
|
||||
.field("server_url", &self.server_url)
|
||||
.field("agent_id", &self.agent_id)
|
||||
.field("agent_token", &"<redacted>")
|
||||
.field("reconnect_base_ms", &self.reconnect_base_ms)
|
||||
.field("reconnect_max_ms", &self.reconnect_max_ms)
|
||||
.field(
|
||||
"observation_sync_interval_seconds",
|
||||
&self.observation_sync_interval_seconds,
|
||||
)
|
||||
.field(
|
||||
"observation_retention_days",
|
||||
&self.observation_retention_days,
|
||||
)
|
||||
.field("reconnect_base", &self.reconnect_base)
|
||||
.field("reconnect_max", &self.reconnect_max)
|
||||
.field("observation_sync_interval", &self.observation_sync_interval)
|
||||
.field("observation_retention", &self.observation_retention)
|
||||
.field("pid_file", &self.pid_file)
|
||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||
@@ -119,20 +121,20 @@ impl fmt::Debug for AgentConfig {
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_reconnect_base_ms() -> u64 {
|
||||
1_000
|
||||
const fn default_reconnect_base() -> Duration {
|
||||
Duration::from_secs(1)
|
||||
}
|
||||
|
||||
const fn default_reconnect_max_ms() -> u64 {
|
||||
30_000
|
||||
const fn default_reconnect_max() -> Duration {
|
||||
Duration::from_secs(30)
|
||||
}
|
||||
|
||||
const fn default_observation_sync_interval_seconds() -> u64 {
|
||||
60
|
||||
const fn default_observation_sync_interval() -> Duration {
|
||||
Duration::from_secs(60)
|
||||
}
|
||||
|
||||
const fn default_observation_retention_days() -> u64 {
|
||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||
const fn default_observation_retention() -> Duration {
|
||||
Duration::from_secs(DEFAULT_OBSERVATION_RETENTION_DAYS * 86_400)
|
||||
}
|
||||
|
||||
fn default_pid_file() -> PathBuf {
|
||||
@@ -159,17 +161,43 @@ const fn default_terminal_max_sessions() -> usize {
|
||||
DEFAULT_TERMINAL_MAX_SESSIONS
|
||||
}
|
||||
|
||||
const fn default_terminal_session_ttl_seconds() -> u64 {
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
const fn default_terminal_session_ttl() -> Duration {
|
||||
Duration::from_secs(DEFAULT_TERMINAL_SESSION_TTL_SECONDS)
|
||||
}
|
||||
|
||||
impl TerminalConfig {
|
||||
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
|
||||
checked_terminal_session_ttl(self.session_ttl_seconds).map_err(anyhow::Error::msg)
|
||||
anyhow::ensure!(
|
||||
self.session_ttl.subsec_nanos() == 0,
|
||||
"terminal.session_ttl must use whole seconds (the terminal protocol uses seconds)"
|
||||
);
|
||||
checked_terminal_session_ttl(self.session_ttl.as_secs()).map_err(anyhow::Error::msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// Validate scheduling bounds and second-based storage/protocol boundaries.
|
||||
/// Zero is allowed for hook retention and terminal TTL, not active timers.
|
||||
fn validate(&self) -> Result<()> {
|
||||
for (name, duration) in [
|
||||
("reconnect_base", self.reconnect_base),
|
||||
("reconnect_max", self.reconnect_max),
|
||||
("observation_sync_interval", self.observation_sync_interval),
|
||||
] {
|
||||
anyhow::ensure!(!duration.is_zero(), "{name} must be positive");
|
||||
anyhow::ensure!(
|
||||
std::time::Instant::now().checked_add(duration).is_some(),
|
||||
"{name} exceeds the platform timer range"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
self.observation_retention.subsec_nanos() == 0,
|
||||
"observation_retention must use whole seconds (hook timestamps use seconds)"
|
||||
);
|
||||
self.terminal.session_ttl()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
||||
vec![
|
||||
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()),
|
||||
@@ -191,13 +219,99 @@ pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config:
|
||||
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
||||
let config: AgentConfig = toml::from_str(&content)
|
||||
let raw: toml::Value = toml::from_str(&content)
|
||||
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
||||
config.terminal.session_ttl()?;
|
||||
let mut value = serde_json::to_value(raw)?;
|
||||
let mut deprecated = config_duration::normalize(&mut value, AGENT_DURATION_KEYS)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.into_iter()
|
||||
.map(|key| ("", key))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(terminal) = value.get_mut("terminal") {
|
||||
deprecated.extend(
|
||||
config_duration::normalize(terminal, TERMINAL_DURATION_KEYS)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.into_iter()
|
||||
.map(|key| ("terminal.", key)),
|
||||
);
|
||||
}
|
||||
let config: AgentConfig = serde_json::from_value(value)
|
||||
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
||||
config.validate()?;
|
||||
for (prefix, key) in deprecated {
|
||||
// Initial config loading precedes tracing setup; stderr also keeps
|
||||
// init-config stdout valid TOML and never prints config secrets.
|
||||
eprintln!(
|
||||
"warning: {}: `{prefix}{}` is deprecated; use `{prefix}{}` with a duration string",
|
||||
path.display(),
|
||||
key.old,
|
||||
key.new
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
const AGENT_DURATION_KEYS: &[LegacyDuration] = &[
|
||||
LegacyDuration {
|
||||
old: "reconnect_base_ms",
|
||||
new: "reconnect_base",
|
||||
unit: Duration::from_millis(1),
|
||||
},
|
||||
LegacyDuration {
|
||||
old: "reconnect_max_ms",
|
||||
new: "reconnect_max",
|
||||
unit: Duration::from_millis(1),
|
||||
},
|
||||
LegacyDuration {
|
||||
old: "observation_sync_interval_seconds",
|
||||
new: "observation_sync_interval",
|
||||
unit: Duration::from_secs(1),
|
||||
},
|
||||
LegacyDuration {
|
||||
old: "observation_retention_days",
|
||||
new: "observation_retention",
|
||||
unit: Duration::from_secs(86_400),
|
||||
},
|
||||
];
|
||||
const TERMINAL_DURATION_KEYS: &[LegacyDuration] = &[LegacyDuration {
|
||||
old: "session_ttl_seconds",
|
||||
new: "session_ttl",
|
||||
unit: Duration::from_secs(1),
|
||||
}];
|
||||
|
||||
impl Serialize for AgentConfig {
|
||||
fn serialize<S: serde::Serializer>(
|
||||
&self,
|
||||
serializer: S,
|
||||
) -> std::result::Result<S::Ok, S::Error> {
|
||||
Self::serialize(self, serializer)
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for AgentConfig {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
config_duration::deserialize_legacy(deserializer, AGENT_DURATION_KEYS, Self::deserialize)
|
||||
}
|
||||
}
|
||||
impl Serialize for TerminalConfig {
|
||||
fn serialize<S: serde::Serializer>(
|
||||
&self,
|
||||
serializer: S,
|
||||
) -> std::result::Result<S::Ok, S::Error> {
|
||||
Self::serialize(self, serializer)
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for TerminalConfig {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
config_duration::deserialize_legacy(deserializer, TERMINAL_DURATION_KEYS, Self::deserialize)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
|
||||
config.validate()?;
|
||||
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
|
||||
write_config_atomically(path, &content)
|
||||
}
|
||||
@@ -262,6 +376,62 @@ fn write_config_atomically(path: &Path, content: &str) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HEADER: &str =
|
||||
"server_url = 'https://example.com'\nagent_id = 'agent-1'\nagent_token = 'secret'\n";
|
||||
|
||||
#[test]
|
||||
fn legacy_duration_keys_convert_and_serialize_canonically() {
|
||||
let legacy: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base_ms = 123\nreconnect_max_ms = 456\nobservation_sync_interval_seconds = 7\nobservation_retention_days = 3\n[terminal]\nsession_ttl_seconds = 600\n")).unwrap();
|
||||
let canonical: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base = '123ms'\nreconnect_max = '456ms'\nobservation_sync_interval = '7s'\nobservation_retention = '3d'\n[terminal]\nsession_ttl = '10m'\n")).unwrap();
|
||||
assert_eq!(legacy, canonical);
|
||||
legacy.validate().unwrap();
|
||||
let output = toml::to_string(&legacy).unwrap();
|
||||
for key in AGENT_DURATION_KEYS {
|
||||
assert!(!output.contains(key.old));
|
||||
assert!(output.contains(key.new));
|
||||
}
|
||||
assert!(!output.contains("session_ttl_seconds"));
|
||||
assert_eq!(toml::from_str::<AgentConfig>(&output).unwrap(), canonical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_rejects_ambiguous_units_and_invalid_timer_values() {
|
||||
for fields in [
|
||||
"reconnect_base = '1s'\nreconnect_base_ms = 1000",
|
||||
"observation_retention = '7d'\nobservation_retention_days = 7",
|
||||
"reconnect_base = 1000",
|
||||
"observation_retention_days = -1",
|
||||
"observation_retention_days = 9223372036854775807",
|
||||
"reconnect_base = 'nonsense'",
|
||||
"[terminal]\nsession_ttl = '1h'\nsession_ttl_seconds = 3600",
|
||||
] {
|
||||
assert!(
|
||||
toml::from_str::<AgentConfig>(&format!("{HEADER}{fields}")).is_err(),
|
||||
"{fields}"
|
||||
);
|
||||
}
|
||||
for fields in [
|
||||
"observation_sync_interval = '0s'",
|
||||
"reconnect_base = '0s'",
|
||||
"[terminal]\nsession_ttl = '500ms'",
|
||||
] {
|
||||
let config: AgentConfig = toml::from_str(&format!("{HEADER}{fields}")).unwrap();
|
||||
assert!(config.validate().is_err(), "{fields}");
|
||||
}
|
||||
let config: AgentConfig = toml::from_str(&format!(
|
||||
"{HEADER}observation_retention = '0d'\n[terminal]\nsession_ttl = '0s'"
|
||||
))
|
||||
.unwrap();
|
||||
config.validate().unwrap();
|
||||
assert!(config.observation_retention.is_zero());
|
||||
assert_eq!(config.terminal.session_ttl().unwrap(), None);
|
||||
let oversized = AgentConfig {
|
||||
reconnect_max: Duration::from_secs(u64::MAX),
|
||||
..config
|
||||
};
|
||||
assert!(oversized.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id()));
|
||||
@@ -270,10 +440,10 @@ mod tests {
|
||||
server_url: "https://example.com".into(),
|
||||
agent_id: "agent-1".into(),
|
||||
agent_token: "secret".into(),
|
||||
reconnect_base_ms: 123,
|
||||
reconnect_max_ms: 456,
|
||||
observation_sync_interval_seconds: 7,
|
||||
observation_retention_days: 3,
|
||||
reconnect_base: Duration::from_millis(123),
|
||||
reconnect_max: Duration::from_millis(456),
|
||||
observation_sync_interval: Duration::from_secs(7),
|
||||
observation_retention: Duration::from_secs(3 * 86_400),
|
||||
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||
@@ -293,7 +463,7 @@ mod tests {
|
||||
args: vec!["-l".into()],
|
||||
current_dir: Some("/tmp".into()),
|
||||
max_sessions: 2,
|
||||
session_ttl_seconds: 600,
|
||||
session_ttl: Duration::from_secs(600),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -323,8 +493,8 @@ agent_token = "secret"
|
||||
.expect("config should parse");
|
||||
|
||||
assert_eq!(
|
||||
config.observation_retention_days,
|
||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||
config.observation_retention,
|
||||
DEFAULT_CONFIG.observation_retention
|
||||
);
|
||||
assert_eq!(config.terminal, TerminalConfig::default());
|
||||
}
|
||||
@@ -348,8 +518,8 @@ max_sessions = 2
|
||||
assert!(config.terminal.args.is_empty());
|
||||
assert!(config.terminal.current_dir.is_none());
|
||||
assert_eq!(
|
||||
config.terminal.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
config.terminal.session_ttl,
|
||||
TerminalConfig::default().session_ttl
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,18 +544,18 @@ max_session = 67
|
||||
#[test]
|
||||
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
|
||||
let mut terminal = TerminalConfig {
|
||||
session_ttl_seconds: 0,
|
||||
session_ttl: Duration::ZERO,
|
||||
..TerminalConfig::default()
|
||||
};
|
||||
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
|
||||
|
||||
terminal.session_ttl_seconds = 67;
|
||||
terminal.session_ttl = Duration::from_secs(67);
|
||||
assert_eq!(
|
||||
terminal.session_ttl().expect("finite TTL"),
|
||||
Some(std::time::Duration::from_secs(67))
|
||||
);
|
||||
|
||||
terminal.session_ttl_seconds = u64::MAX;
|
||||
terminal.session_ttl = Duration::from_secs(u64::MAX);
|
||||
assert!(terminal.session_ttl().is_err());
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,9 @@ pub async fn inventory_for_config(
|
||||
.instrument(tracing::info_span!("inventory_dhcp_leases"))
|
||||
.await?;
|
||||
tracing::Span::current().record("leases", leases.len());
|
||||
match wakey::wakey_linux::observations::prune_stale_observations_from_path(
|
||||
match wakey::wakey_linux::observations::prune_expired(
|
||||
&config.observation_store_path,
|
||||
config.observation_retention_days,
|
||||
config.observation_retention,
|
||||
)
|
||||
.instrument(tracing::info_span!("inventory_observation_prune"))
|
||||
.await
|
||||
|
||||
@@ -143,10 +143,10 @@ mod tests {
|
||||
server_url: "https://old.example.com".into(),
|
||||
agent_id: "old-agent".into(),
|
||||
agent_token: "old-token".into(),
|
||||
reconnect_base_ms: 2_000,
|
||||
reconnect_max_ms: 60_000,
|
||||
observation_sync_interval_seconds: 30,
|
||||
observation_retention_days: 11,
|
||||
reconnect_base: std::time::Duration::from_secs(2),
|
||||
reconnect_max: std::time::Duration::from_secs(60),
|
||||
observation_sync_interval: std::time::Duration::from_secs(30),
|
||||
observation_retention: std::time::Duration::from_secs(11 * 86_400),
|
||||
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
||||
@@ -164,7 +164,7 @@ mod tests {
|
||||
args: vec!["-l".into()],
|
||||
current_dir: Some("/root".into()),
|
||||
max_sessions: 2,
|
||||
session_ttl_seconds: 600,
|
||||
session_ttl: std::time::Duration::from_secs(600),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,7 +176,10 @@ mod tests {
|
||||
assert_eq!(config.agent_id, "agent-123");
|
||||
assert_eq!(config.agent_token, "token-xyz");
|
||||
assert_eq!(config.server_url, "https://control.example.com");
|
||||
assert_eq!(config.observation_retention_days, 11);
|
||||
assert_eq!(
|
||||
config.observation_retention,
|
||||
base_config.observation_retention
|
||||
);
|
||||
assert_eq!(config.pid_file, base_config.pid_file);
|
||||
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||
assert_eq!(config.terminal, base_config.terminal);
|
||||
|
||||
@@ -290,7 +290,10 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
cfg.agent_token = agent_token;
|
||||
}
|
||||
if let Some(days) = args.observation_retention_days {
|
||||
cfg.observation_retention_days = days;
|
||||
cfg.observation_retention = std::time::Duration::from_secs(
|
||||
days.checked_mul(86_400)
|
||||
.ok_or_else(|| anyhow::anyhow!("observation retention is too large"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(path) = &args.config {
|
||||
|
||||
+29
-17
@@ -18,16 +18,16 @@ pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
// Terminal workers belong to the agent process, not a single control socket.
|
||||
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
|
||||
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config);
|
||||
let mut backoff = config.reconnect_base_ms.max(100);
|
||||
let mut backoff = config.reconnect_base.max(Duration::from_millis(100));
|
||||
loop {
|
||||
match run_once(&config, &terminal_manager, &mut terminal_events).await {
|
||||
Ok(()) => {
|
||||
backoff = config.reconnect_base_ms.max(100);
|
||||
backoff = config.reconnect_base.max(Duration::from_millis(100));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff, "agent session ended; reconnecting");
|
||||
sleep(Duration::from_millis(backoff)).await;
|
||||
backoff = next_backoff_ms(backoff, config.reconnect_max_ms);
|
||||
warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff.as_millis() as u64, "agent session ended; reconnecting");
|
||||
sleep(backoff).await;
|
||||
backoff = next_backoff(backoff, config.reconnect_max);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ async fn run_once(
|
||||
.enabled
|
||||
.then_some(TerminalCapabilityOptions {
|
||||
max_sessions: config.terminal.max_sessions.max(1),
|
||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
||||
session_ttl_seconds: config.terminal.session_ttl.as_secs(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -117,9 +117,7 @@ async fn run_once(
|
||||
|
||||
let mut heartbeat = interval(Duration::from_secs(30));
|
||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
let mut snapshot_sync = interval(Duration::from_secs(
|
||||
config.observation_sync_interval_seconds.max(1),
|
||||
));
|
||||
let mut snapshot_sync = interval(config.observation_sync_interval);
|
||||
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
snapshot_sync.reset();
|
||||
loop {
|
||||
@@ -238,9 +236,9 @@ where
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
let cap = max_ms.max(current_ms);
|
||||
current_ms.saturating_mul(2).min(cap)
|
||||
pub fn next_backoff(current: Duration, max: Duration) -> Duration {
|
||||
let cap = max.max(current);
|
||||
current.saturating_mul(2).min(cap)
|
||||
}
|
||||
|
||||
async fn handle_server_message<S>(
|
||||
@@ -474,7 +472,7 @@ mod tests {
|
||||
use serde_json::Value;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::{next_backoff_ms, send_command_result, websocket_url};
|
||||
use super::{next_backoff, send_command_result, websocket_url};
|
||||
use crate::protocol::{CommandResult, RequestId};
|
||||
|
||||
struct RecordingSink {
|
||||
@@ -533,14 +531,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn backoff_doubles_until_cap() {
|
||||
assert_eq!(next_backoff_ms(1_000, 30_000), 2_000);
|
||||
assert_eq!(next_backoff_ms(16_000, 30_000), 30_000);
|
||||
assert_eq!(next_backoff_ms(30_000, 30_000), 30_000);
|
||||
use std::time::Duration;
|
||||
assert_eq!(
|
||||
next_backoff(Duration::from_secs(1), Duration::from_secs(30)),
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
assert_eq!(
|
||||
next_backoff(Duration::from_secs(16), Duration::from_secs(30)),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
assert_eq!(
|
||||
next_backoff(Duration::from_secs(30), Duration::from_secs(30)),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_never_shrinks_when_max_is_lower() {
|
||||
assert_eq!(next_backoff_ms(8_000, 1_000), 8_000);
|
||||
use std::time::Duration;
|
||||
assert_eq!(
|
||||
next_backoff(Duration::from_secs(8), Duration::from_secs(1)),
|
||||
Duration::from_secs(8)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -224,7 +224,7 @@ impl TerminalManager {
|
||||
cancel: Some(cancel_tx),
|
||||
relay_credentials: relay_tx.clone(),
|
||||
created_at_unix,
|
||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
||||
session_ttl_seconds: config.terminal.session_ttl.as_secs(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user