Skip to main content

vtcode_config/
timeouts.rs

1use anyhow::{Result, ensure};
2use serde::{Deserialize, Serialize};
3
4/// Floor for the HTTP timeout keys. Values in `1..MIN` are almost always a unit
5/// mistake (a caller meaning milliseconds), so they are rejected at load time
6/// rather than turned into reliably-failing clients.
7const 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    /// Maximum duration (in seconds) for standard, non-PTY tools.
13    #[serde(default = "TimeoutsConfig::default_default_ceiling_seconds")]
14    pub default_ceiling_seconds: u64,
15    /// Maximum duration (in seconds) for PTY-backed commands.
16    #[serde(default = "TimeoutsConfig::default_pty_ceiling_seconds")]
17    pub pty_ceiling_seconds: u64,
18    /// Maximum duration (in seconds) for MCP calls.
19    #[serde(default = "TimeoutsConfig::default_mcp_ceiling_seconds")]
20    pub mcp_ceiling_seconds: u64,
21    /// Maximum duration (in seconds) for streaming API responses.
22    #[serde(default = "TimeoutsConfig::default_streaming_ceiling_seconds")]
23    pub streaming_ceiling_seconds: u64,
24    /// Maximum duration (in seconds) for an explicit long-running command wait.
25    #[serde(default = "TimeoutsConfig::default_long_running_command_ceiling_seconds")]
26    pub long_running_command_ceiling_seconds: u64,
27    /// Percentage (0-100) of the ceiling after which the UI should warn.
28    #[serde(default = "TimeoutsConfig::default_warning_threshold_percent")]
29    pub warning_threshold_percent: u8,
30    /// Adaptive timeout decay ratio (0.1-1.0). Lower relaxes faster back to ceiling.
31    #[serde(default = "TimeoutsConfig::default_decay_ratio")]
32    pub adaptive_decay_ratio: f64,
33    /// Number of consecutive successes before relaxing adaptive ceiling.
34    #[serde(default = "TimeoutsConfig::default_success_streak")]
35    pub adaptive_success_streak: u32,
36    /// Minimum timeout floor in milliseconds when applying adaptive clamps.
37    #[serde(default = "TimeoutsConfig::default_min_floor_ms")]
38    pub adaptive_min_floor_ms: u64,
39    /// Timeout (in seconds) for the connect phase of outbound HTTP clients.
40    /// `0` disables the limit. Also settable via `VTCODE_HTTP_CONNECT_TIMEOUT_SECS`.
41    #[serde(default = "TimeoutsConfig::default_http_connect_seconds")]
42    pub http_connect_seconds: u64,
43    /// Timeout (in seconds) for a whole outbound HTTP request, including body.
44    /// `0` disables the limit. Also settable via `VTCODE_HTTP_REQUEST_TIMEOUT_SECS`.
45    #[serde(default = "TimeoutsConfig::default_http_request_seconds")]
46    pub http_request_seconds: u64,
47    /// Timeout (in seconds) for large HTTP transfers such as release downloads.
48    /// `0` disables the limit. Also settable via `VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS`.
49    #[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    // The HTTP keys are the escape hatch for the "my connection is slower than
112    // the author's" case, so they also honor an environment variable. Matching
113    // `VTCODE_FULLSCREEN_SCROLL_SPEED`, the env var applies only when the TOML
114    // key is absent, which keeps `vtcode.toml` authoritative when both are set.
115    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    /// Convert the configured threshold into a fraction (0.0-1.0).
128    pub fn warning_threshold_fraction(&self) -> f32 {
129        f32::from(self.warning_threshold_percent) / 100.0
130    }
131
132    /// Resolve the outbound HTTP timeout defaults for
133    /// [`vtcode_commons::http::install_http_timeouts`].
134    #[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    /// Normalize a ceiling value into an optional duration.
144    #[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
212/// Parse a `VTCODE_*_TIMEOUT_SECS`-style environment override.
213///
214/// Empty, non-numeric, and zero values are ignored so a stray `=0` in a CI
215/// environment cannot silently disable every HTTP deadline.
216fn 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/// Resolve a user-supplied timeout into a bounded, non-zero value.
223///
224/// Single runtime entry point; the bounds live in
225/// [`crate::constants::execution::resolve_timeout`] so the two spellings
226/// cannot drift apart.
227#[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        // `#[serde(default)]` resolves through the env-aware fns, so an unset
335        // TOML key can still be steered from the environment.
336        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        // A zero override must not silently disable the deadline.
342        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}