Make the app-server thread unload delay configurable (#42320)

## What changed

- Add the top-level `thread_unload_delay_secs` configuration key for the
  app-server, with a 60-second default instead of the previous 30-minute
  fixed delay.
- Allow zero-delay unloading and reject values that cannot fit in a
  monotonic-clock deadline.
- Reset the unload countdown when a thread gains a subscriber or becomes
  active, and preserve active turns even when the configured delay is zero.
- Document that unloading ephemeral threads discards their in-memory state.

## Testing

- Cover configuration parsing, defaults, overrides, and overflow rejection.
- Cover unsubscribe, resubscribe, delayed and immediate unloading,
  notifications, persisted-thread resume, and WebSocket disconnect behavior.

GitOrigin-RevId: edd46f6b49bbdafbf606bff74378e8f569e95977
This commit is contained in:
Eric Traut authored and copyberry committed 2026-09-02 18:18:54 +00:00
1 parent a526f54b00
commit 5e26f7621c
11 files changed
+238 -13

No files matched your search

+37
View File
@@ -247,6 +247,43 @@ async fn load_config_applies_optional_mcp_startup_grace() -> std::io::Result<()>
Ok(())
}
#[tokio::test]
async fn load_config_resolves_thread_unload_delay() -> anyhow::Result<()> {
let codex_home = tempdir()?;
for (toml, seconds) in [
("", 60),
("thread_unload_delay_secs = 0", 0),
("thread_unload_delay_secs = 1800", 1800),
] {
let config = Config::load_from_base_config_with_overrides(
toml::from_str(toml)?,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(config.thread_unload_delay, Duration::from_secs(seconds));
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_thread_unload_delay_overflow() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let error = Config::load_from_base_config_with_overrides(
ConfigToml {
thread_unload_delay_secs: Some(u64::MAX),
..Default::default()
},
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("idle timeout must fit in a monotonic deadline");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(error.to_string(), "thread_unload_delay_secs is too large");
Ok(())
}
#[tokio::test]
async fn test_toml_parsing() {
let history_with_persistence = r#"
+15
View File
@@ -1037,6 +1037,9 @@ pub struct Config {
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: u64,
/// Idle timeout for unsubscribed app-server threads, resolved at server startup.
pub thread_unload_delay: Duration,
/// Compatibility-only settings retained for legacy `ghost_snapshot`
/// config loading.
pub ghost_snapshot: GhostSnapshotConfig,
@@ -3813,6 +3816,17 @@ impl Config {
.background_terminal_max_timeout
.unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS)
.max(MIN_EMPTY_YIELD_TIME_MS);
let thread_unload_delay =
Duration::from_secs(cfg.thread_unload_delay_secs.unwrap_or(/*default*/ 60));
if std::time::Instant::now()
.checked_add(thread_unload_delay)
.is_none()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"thread_unload_delay_secs is too large",
));
}
let ghost_snapshot = {
let mut config = GhostSnapshotConfig::default();
@@ -4298,6 +4312,7 @@ impl Config {
tool_registry,
code_mode,
background_terminal_max_timeout,
thread_unload_delay,
ghost_snapshot,
multi_agent_v2,
token_budget,