Files
codex/codex-rs/app-server-daemon/src/settings.rs
T
Eric Traut a51608398d Make the managed app-server shutdown grace period configurable (#43572)
## Why

Managed app-server shutdowns use a fixed 60-second grace period. Allow users to adjust how long shutdown waits before forcing the process to exit.

## What changed

- Add `shutdownGraceSeconds` to daemon settings, accepting integers from 0 through 300 and defaulting to 60. Zero requests graceful shutdown and then forces termination immediately.
- Apply the setting to managed app-server stops and restarts, including updater-triggered restarts, and extend the lifecycle lock timeout to accommodate the maximum grace period.
- Keep `stop` usable with unreadable or invalid settings by falling back to the default grace period.

## Testing

Add tests for setting defaults and bounds, invalid-value fallback, preservation when saving remote-control settings, graceful process exit, and forced termination with zero or finite grace periods.

GitOrigin-RevId: 8627b391323cf27b40df96935a82e66aa715f572
2026-09-07 19:51:53 +00:00

196 lines
6.0 KiB
Rust

use std::path::Path;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Map;
use serde_json::Value;
use tokio::fs;
pub(crate) const DEFAULT_UPDATE_INTERVAL_MINUTES: u32 = 60;
pub(crate) const DEFAULT_SHUTDOWN_GRACE_SECONDS: u32 = 60;
pub(crate) const MAX_SHUTDOWN_GRACE_SECONDS: u32 = 5 * 60;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DaemonSettings {
pub(crate) remote_control_enabled: bool,
pub(crate) auto_update_enabled: bool,
pub(crate) update_interval_minutes: u32,
pub(crate) shutdown_grace_seconds: u32,
}
impl Default for DaemonSettings {
fn default() -> Self {
Self {
remote_control_enabled: false,
auto_update_enabled: true,
update_interval_minutes: DEFAULT_UPDATE_INTERVAL_MINUTES,
shutdown_grace_seconds: DEFAULT_SHUTDOWN_GRACE_SECONDS,
}
}
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StopSettings {
shutdown_grace_seconds: Option<u32>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredSettings {
#[serde(default)]
remote_control_enabled: bool,
#[serde(default = "default_shutdown_grace_seconds")]
shutdown_grace_seconds: u32,
#[serde(default)]
updater: UpdaterSettings,
}
impl Default for StoredSettings {
fn default() -> Self {
Self {
remote_control_enabled: false,
shutdown_grace_seconds: DEFAULT_SHUTDOWN_GRACE_SECONDS,
updater: UpdaterSettings::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdaterSettings {
#[serde(default = "default_auto_update_enabled")]
pub(crate) auto_update_enabled: bool,
#[serde(default = "default_update_interval_minutes")]
pub(crate) update_interval_minutes: u32,
}
impl Default for UpdaterSettings {
fn default() -> Self {
Self {
auto_update_enabled: default_auto_update_enabled(),
update_interval_minutes: default_update_interval_minutes(),
}
}
}
fn default_auto_update_enabled() -> bool {
true
}
fn default_update_interval_minutes() -> u32 {
DEFAULT_UPDATE_INTERVAL_MINUTES
}
fn default_shutdown_grace_seconds() -> u32 {
DEFAULT_SHUTDOWN_GRACE_SECONDS
}
fn validate_shutdown_grace(seconds: u32) -> Result<()> {
ensure!(
seconds <= MAX_SHUTDOWN_GRACE_SECONDS,
"shutdown grace must be between 0 and {MAX_SHUTDOWN_GRACE_SECONDS} seconds"
);
Ok(())
}
impl UpdaterSettings {
pub(crate) async fn load(settings_file: &Path) -> Result<Self> {
let settings: StoredSettings = read_settings(settings_file).await?;
validate_shutdown_grace(settings.shutdown_grace_seconds)?;
let settings = settings.updater;
settings.validate()?;
Ok(settings)
}
pub(crate) fn validate(&self) -> Result<()> {
ensure!(
self.update_interval_minutes > 0,
"update interval must be positive"
);
Ok(())
}
pub(crate) fn update_interval(&self, minute: Duration) -> Duration {
minute * self.update_interval_minutes
}
}
impl DaemonSettings {
pub(crate) async fn load(path: &Path) -> Result<Self> {
let settings: StoredSettings = read_settings(path).await?;
settings.updater.validate()?;
validate_shutdown_grace(settings.shutdown_grace_seconds)?;
Ok(Self {
remote_control_enabled: settings.remote_control_enabled,
auto_update_enabled: settings.updater.auto_update_enabled,
update_interval_minutes: settings.updater.update_interval_minutes,
shutdown_grace_seconds: settings.shutdown_grace_seconds,
})
}
pub(crate) async fn load_for_stop(path: &Path) -> Self {
// Stop must work even when settings are unreadable or partially edited.
let shutdown_grace_seconds = read_settings::<StopSettings>(path)
.await
.ok()
.and_then(|settings| settings.shutdown_grace_seconds)
.filter(|&seconds| seconds <= MAX_SHUTDOWN_GRACE_SECONDS)
.unwrap_or(DEFAULT_SHUTDOWN_GRACE_SECONDS);
Self {
shutdown_grace_seconds,
..Self::default()
}
}
pub(crate) async fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.with_context(|| {
format!(
"failed to create daemon settings directory {}",
parent.display()
)
})?;
}
let mut settings: Map<String, Value> = read_settings(path).await?;
settings.insert(
"remoteControlEnabled".to_string(),
Value::Bool(self.remote_control_enabled),
);
let contents =
serde_json::to_vec_pretty(&settings).context("failed to serialize settings")?;
let temporary_path = path.with_extension("tmp");
fs::write(&temporary_path, contents)
.await
.with_context(|| {
format!(
"failed to write daemon settings {}",
temporary_path.display()
)
})?;
fs::rename(&temporary_path, path)
.await
.with_context(|| format!("failed to replace daemon settings {}", path.display()))
}
}
async fn read_settings<T: DeserializeOwned + Default>(path: &Path) -> Result<T> {
let contents = match fs::read_to_string(path).await {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
Err(err) => {
return Err(err).with_context(|| format!("failed to read settings {}", path.display()));
}
};
serde_json::from_str(&contents)
.with_context(|| format!("failed to parse settings {}", path.display()))
}
#[cfg(test)]
#[path = "settings_tests.rs"]
mod tests;