1use anyhow::{Result, ensure};
2use serde::{Deserialize, Serialize};
3
4const MIN_HTTP_TIMEOUT_SECONDS: u64 = 5;
8
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct TimeoutsConfig {
12 #[serde(default = "TimeoutsConfig::default_default_ceiling_seconds")]
14 pub default_ceiling_seconds: u64,
15 #[serde(default = "TimeoutsConfig::default_pty_ceiling_seconds")]
17 pub pty_ceiling_seconds: u64,
18 #[serde(default = "TimeoutsConfig::default_mcp_ceiling_seconds")]
20 pub mcp_ceiling_seconds: u64,
21 #[serde(default = "TimeoutsConfig::default_streaming_ceiling_seconds")]
23 pub streaming_ceiling_seconds: u64,
24 #[serde(default = "TimeoutsConfig::default_long_running_command_ceiling_seconds")]
26 pub long_running_command_ceiling_seconds: u64,
27 #[serde(default = "TimeoutsConfig::default_warning_threshold_percent")]
29 pub warning_threshold_percent: u8,
30 #[serde(default = "TimeoutsConfig::default_decay_ratio")]
32 pub adaptive_decay_ratio: f64,
33 #[serde(default = "TimeoutsConfig::default_success_streak")]
35 pub adaptive_success_streak: u32,
36 #[serde(default = "TimeoutsConfig::default_min_floor_ms")]
38 pub adaptive_min_floor_ms: u64,
39 #[serde(default = "TimeoutsConfig::default_http_connect_seconds")]
42 pub http_connect_seconds: u64,
43 #[serde(default = "TimeoutsConfig::default_http_request_seconds")]
46 pub http_request_seconds: u64,
47 #[serde(default = "TimeoutsConfig::default_http_download_seconds")]
50 pub http_download_seconds: u64,
51}
52
53impl Default for TimeoutsConfig {
54 fn default() -> Self {
55 Self {
56 default_ceiling_seconds: Self::default_default_ceiling_seconds(),
57 pty_ceiling_seconds: Self::default_pty_ceiling_seconds(),
58 mcp_ceiling_seconds: Self::default_mcp_ceiling_seconds(),
59 streaming_ceiling_seconds: Self::default_streaming_ceiling_seconds(),
60 long_running_command_ceiling_seconds: Self::default_long_running_command_ceiling_seconds(),
61 warning_threshold_percent: Self::default_warning_threshold_percent(),
62 adaptive_decay_ratio: Self::default_decay_ratio(),
63 adaptive_success_streak: Self::default_success_streak(),
64 adaptive_min_floor_ms: Self::default_min_floor_ms(),
65 http_connect_seconds: Self::default_http_connect_seconds(),
66 http_request_seconds: Self::default_http_request_seconds(),
67 http_download_seconds: Self::default_http_download_seconds(),
68 }
69 }
70}
71
72impl TimeoutsConfig {
73 const MIN_CEILING_SECONDS: u64 = 15;
74
75 const fn default_default_ceiling_seconds() -> u64 {
76 180
77 }
78
79 const fn default_pty_ceiling_seconds() -> u64 {
80 300
81 }
82
83 const fn default_mcp_ceiling_seconds() -> u64 {
84 120
85 }
86
87 const fn default_streaming_ceiling_seconds() -> u64 {
88 600
89 }
90
91 const fn default_long_running_command_ceiling_seconds() -> u64 {
92 3_600
93 }
94
95 const fn default_warning_threshold_percent() -> u8 {
96 80
97 }
98
99 const fn default_decay_ratio() -> f64 {
100 0.875
101 }
102
103 const fn default_success_streak() -> u32 {
104 5
105 }
106
107 const fn default_min_floor_ms() -> u64 {
108 1_000
109 }
110
111 fn default_http_connect_seconds() -> u64 {
116 env_seconds(crate::constants::env_vars::VTCODE_HTTP_CONNECT_TIMEOUT_SECS).unwrap_or(5)
117 }
118
119 fn default_http_request_seconds() -> u64 {
120 env_seconds(crate::constants::env_vars::VTCODE_HTTP_REQUEST_TIMEOUT_SECS).unwrap_or(30)
121 }
122
123 fn default_http_download_seconds() -> u64 {
124 env_seconds(crate::constants::env_vars::VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS).unwrap_or(300)
125 }
126
127 pub fn warning_threshold_fraction(&self) -> f32 {
129 f32::from(self.warning_threshold_percent) / 100.0
130 }
131
132 #[must_use]
135 pub fn http_timeouts(&self) -> vtcode_commons::http::HttpTimeouts {
136 vtcode_commons::http::HttpTimeouts::from_seconds(
137 self.http_connect_seconds,
138 self.http_request_seconds,
139 self.http_download_seconds,
140 )
141 }
142
143 #[must_use]
145 pub fn ceiling_duration(&self, seconds: u64) -> Option<std::time::Duration> {
146 if seconds == 0 {
147 None
148 } else {
149 Some(std::time::Duration::from_secs(seconds))
150 }
151 }
152
153 pub(crate) fn validate(&self) -> Result<()> {
154 ensure!(
155 self.warning_threshold_percent > 0 && self.warning_threshold_percent < 100,
156 "timeouts.warning_threshold_percent must be between 1 and 99",
157 );
158
159 ensure!(
160 (0.1..=1.0).contains(&self.adaptive_decay_ratio),
161 "timeouts.adaptive_decay_ratio must be between 0.1 and 1.0"
162 );
163 ensure!(self.adaptive_success_streak > 0, "timeouts.adaptive_success_streak must be at least 1");
164 ensure!(self.adaptive_min_floor_ms >= 100, "timeouts.adaptive_min_floor_ms must be at least 100ms");
165
166 ensure!(
167 self.default_ceiling_seconds == 0 || self.default_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
168 "timeouts.default_ceiling_seconds must be at least {} seconds (or 0 to disable)",
169 Self::MIN_CEILING_SECONDS
170 );
171
172 ensure!(
173 self.pty_ceiling_seconds == 0 || self.pty_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
174 "timeouts.pty_ceiling_seconds must be at least {} seconds (or 0 to disable)",
175 Self::MIN_CEILING_SECONDS
176 );
177
178 ensure!(
179 self.mcp_ceiling_seconds == 0 || self.mcp_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
180 "timeouts.mcp_ceiling_seconds must be at least {} seconds (or 0 to disable)",
181 Self::MIN_CEILING_SECONDS
182 );
183
184 ensure!(
185 self.streaming_ceiling_seconds == 0 || self.streaming_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
186 "timeouts.streaming_ceiling_seconds must be at least {} seconds (or 0 to disable)",
187 Self::MIN_CEILING_SECONDS
188 );
189
190 ensure!(
191 self.long_running_command_ceiling_seconds == 0
192 || self.long_running_command_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
193 "timeouts.long_running_command_ceiling_seconds must be at least {} seconds (or 0 to disable)",
194 Self::MIN_CEILING_SECONDS
195 );
196
197 for (name, seconds) in [
198 ("http_connect_seconds", self.http_connect_seconds),
199 ("http_request_seconds", self.http_request_seconds),
200 ("http_download_seconds", self.http_download_seconds),
201 ] {
202 ensure!(
203 seconds == 0 || seconds >= MIN_HTTP_TIMEOUT_SECONDS,
204 "timeouts.{name} must be at least {MIN_HTTP_TIMEOUT_SECONDS} seconds (or 0 to disable)",
205 );
206 }
207
208 Ok(())
209 }
210}
211
212fn env_seconds(name: &str) -> Option<u64> {
217 crate::env_helpers::read_env_var(name)
218 .and_then(|value| value.trim().parse::<u64>().ok())
219 .filter(|v| *v > 0)
220}
221
222#[must_use]
228pub fn resolve_timeout(user_timeout: Option<u64>) -> u64 {
229 crate::constants::execution::resolve_timeout(user_timeout)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::MIN_HTTP_TIMEOUT_SECONDS;
235 use super::TimeoutsConfig;
236 use super::resolve_timeout;
237 use crate::constants::execution::{DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
238
239 #[test]
240 fn default_values_are_safe() {
241 let config = TimeoutsConfig::default();
242 assert_eq!(config.default_ceiling_seconds, 180);
243 assert_eq!(config.pty_ceiling_seconds, 300);
244 assert_eq!(config.mcp_ceiling_seconds, 120);
245 assert_eq!(config.streaming_ceiling_seconds, 600);
246 assert_eq!(config.long_running_command_ceiling_seconds, 3_600);
247 assert_eq!(config.warning_threshold_percent, 80);
248 assert!(config.validate().is_ok());
249 }
250
251 #[test]
252 fn zero_ceiling_disables_limit() {
253 let config = TimeoutsConfig { default_ceiling_seconds: 0, ..Default::default() };
254 assert!(config.validate().is_ok());
255 assert!(config.ceiling_duration(config.default_ceiling_seconds).is_none());
256 }
257
258 #[test]
259 fn nonzero_ceiling_maps_to_exact_duration() {
260 let config = TimeoutsConfig::default();
261 assert_eq!(config.ceiling_duration(1), Some(std::time::Duration::from_secs(1)));
262 assert_eq!(config.ceiling_duration(3_600), Some(std::time::Duration::from_secs(3_600)));
263 assert_eq!(config.ceiling_duration(u64::MAX), Some(std::time::Duration::from_secs(u64::MAX)));
264 }
265
266 #[test]
267 fn warning_threshold_bounds_are_enforced() {
268 let config_low = TimeoutsConfig { warning_threshold_percent: 0, ..Default::default() };
269 assert!(config_low.validate().is_err());
270
271 let config_high = TimeoutsConfig {
272 warning_threshold_percent: 100,
273 ..Default::default()
274 };
275 assert!(config_high.validate().is_err());
276 }
277
278 #[test]
279 fn resolve_timeout_applies_bounds() {
280 assert_eq!(resolve_timeout(None), DEFAULT_TIMEOUT_SECS);
281 assert_eq!(resolve_timeout(Some(0)), DEFAULT_TIMEOUT_SECS);
282 assert_eq!(resolve_timeout(Some(1)), MIN_TIMEOUT_SECS);
283 assert_eq!(resolve_timeout(Some(MAX_TIMEOUT_SECS + 1)), MAX_TIMEOUT_SECS);
284 assert_eq!(resolve_timeout(Some(120)), 120);
285 }
286
287 #[test]
288 fn http_timeout_defaults_match_commons_http_defaults() {
289 let config = TimeoutsConfig::default();
290 assert_eq!(config.http_connect_seconds, 5);
291 assert_eq!(config.http_request_seconds, 30);
292 assert_eq!(config.http_download_seconds, 300);
293
294 let resolved = config.http_timeouts();
295 let defaults = vtcode_commons::http::HttpTimeouts::default();
296 assert_eq!(resolved, defaults);
297 }
298
299 #[test]
300 fn http_timeouts_map_zero_to_disabled() {
301 let config = TimeoutsConfig {
302 http_connect_seconds: 0,
303 http_request_seconds: 0,
304 http_download_seconds: 0,
305 ..Default::default()
306 };
307 assert!(config.validate().is_ok());
308
309 let resolved = config.http_timeouts();
310 assert_eq!(resolved.connect, None);
311 assert_eq!(resolved.request, None);
312 assert_eq!(resolved.download, None);
313 }
314
315 #[test]
316 fn http_timeouts_reject_sub_second_typos_but_allow_minimum() {
317 for seconds in 1..MIN_HTTP_TIMEOUT_SECONDS {
318 let config = TimeoutsConfig {
319 http_request_seconds: seconds,
320 ..Default::default()
321 };
322 assert!(config.validate().is_err(), "{seconds}s must be rejected as a likely unit mistake");
323 }
324
325 let config = TimeoutsConfig {
326 http_request_seconds: MIN_HTTP_TIMEOUT_SECONDS,
327 ..Default::default()
328 };
329 assert!(config.validate().is_ok());
330 }
331
332 #[test]
333 fn http_env_overrides_back_the_defaults() {
334 let defaults = TimeoutsConfig::default();
337 assert_eq!(defaults.http_request_seconds, 30);
338
339 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_REQUEST_TIMEOUT_SECS", Some("120"));
340 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_CONNECT_TIMEOUT_SECS", Some("15"));
341 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS", Some("0"));
343
344 let overridden = TimeoutsConfig::default();
345 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_REQUEST_TIMEOUT_SECS", None);
346 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_CONNECT_TIMEOUT_SECS", None);
347 crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS", None);
348
349 assert_eq!(overridden.http_request_seconds, 120);
350 assert_eq!(overridden.http_connect_seconds, 15);
351 assert_eq!(overridden.http_download_seconds, 300, "a zero env override is ignored");
352 }
353}