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    pub fn http_timeouts(&self) -> vtcode_commons::http::HttpTimeouts {
135        vtcode_commons::http::HttpTimeouts::from_seconds(
136            self.http_connect_seconds,
137            self.http_request_seconds,
138            self.http_download_seconds,
139        )
140    }
141
142    /// Normalize a ceiling value into an optional duration.
143    pub fn ceiling_duration(&self, seconds: u64) -> Option<std::time::Duration> {
144        if seconds == 0 {
145            None
146        } else {
147            Some(std::time::Duration::from_secs(seconds))
148        }
149    }
150
151    pub(crate) fn validate(&self) -> Result<()> {
152        ensure!(
153            self.warning_threshold_percent > 0 && self.warning_threshold_percent < 100,
154            "timeouts.warning_threshold_percent must be between 1 and 99",
155        );
156
157        ensure!(
158            (0.1..=1.0).contains(&self.adaptive_decay_ratio),
159            "timeouts.adaptive_decay_ratio must be between 0.1 and 1.0"
160        );
161        ensure!(self.adaptive_success_streak > 0, "timeouts.adaptive_success_streak must be at least 1");
162        ensure!(self.adaptive_min_floor_ms >= 100, "timeouts.adaptive_min_floor_ms must be at least 100ms");
163
164        ensure!(
165            self.default_ceiling_seconds == 0 || self.default_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
166            "timeouts.default_ceiling_seconds must be at least {} seconds (or 0 to disable)",
167            Self::MIN_CEILING_SECONDS
168        );
169
170        ensure!(
171            self.pty_ceiling_seconds == 0 || self.pty_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
172            "timeouts.pty_ceiling_seconds must be at least {} seconds (or 0 to disable)",
173            Self::MIN_CEILING_SECONDS
174        );
175
176        ensure!(
177            self.mcp_ceiling_seconds == 0 || self.mcp_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
178            "timeouts.mcp_ceiling_seconds must be at least {} seconds (or 0 to disable)",
179            Self::MIN_CEILING_SECONDS
180        );
181
182        ensure!(
183            self.streaming_ceiling_seconds == 0 || self.streaming_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
184            "timeouts.streaming_ceiling_seconds must be at least {} seconds (or 0 to disable)",
185            Self::MIN_CEILING_SECONDS
186        );
187
188        ensure!(
189            self.long_running_command_ceiling_seconds == 0
190                || self.long_running_command_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
191            "timeouts.long_running_command_ceiling_seconds must be at least {} seconds (or 0 to disable)",
192            Self::MIN_CEILING_SECONDS
193        );
194
195        for (name, seconds) in [
196            ("http_connect_seconds", self.http_connect_seconds),
197            ("http_request_seconds", self.http_request_seconds),
198            ("http_download_seconds", self.http_download_seconds),
199        ] {
200            ensure!(
201                seconds == 0 || seconds >= MIN_HTTP_TIMEOUT_SECONDS,
202                "timeouts.{name} must be at least {MIN_HTTP_TIMEOUT_SECONDS} seconds (or 0 to disable)",
203            );
204        }
205
206        Ok(())
207    }
208}
209
210/// Parse a `VTCODE_*_TIMEOUT_SECS`-style environment override.
211///
212/// Empty, non-numeric, and zero values are ignored so a stray `=0` in a CI
213/// environment cannot silently disable every HTTP deadline.
214fn env_seconds(name: &str) -> Option<u64> {
215    crate::env_helpers::read_env_var(name)
216        .and_then(|value| value.trim().parse::<u64>().ok())
217        .filter(|v| *v > 0)
218}
219
220/// Resolve a user-supplied timeout into a bounded, non-zero value.
221pub fn resolve_timeout(user_timeout: Option<u64>) -> u64 {
222    use crate::constants::execution::{DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
223
224    match user_timeout {
225        None | Some(0) => DEFAULT_TIMEOUT_SECS,
226        Some(value) if value < MIN_TIMEOUT_SECS => MIN_TIMEOUT_SECS,
227        Some(value) if value > MAX_TIMEOUT_SECS => MAX_TIMEOUT_SECS,
228        Some(value) => value,
229    }
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 warning_threshold_bounds_are_enforced() {
260        let config_low = TimeoutsConfig { warning_threshold_percent: 0, ..Default::default() };
261        assert!(config_low.validate().is_err());
262
263        let config_high = TimeoutsConfig {
264            warning_threshold_percent: 100,
265            ..Default::default()
266        };
267        assert!(config_high.validate().is_err());
268    }
269
270    #[test]
271    fn resolve_timeout_applies_bounds() {
272        assert_eq!(resolve_timeout(None), DEFAULT_TIMEOUT_SECS);
273        assert_eq!(resolve_timeout(Some(0)), DEFAULT_TIMEOUT_SECS);
274        assert_eq!(resolve_timeout(Some(1)), MIN_TIMEOUT_SECS);
275        assert_eq!(resolve_timeout(Some(MAX_TIMEOUT_SECS + 1)), MAX_TIMEOUT_SECS);
276        assert_eq!(resolve_timeout(Some(120)), 120);
277    }
278
279    #[test]
280    fn http_timeout_defaults_match_commons_http_defaults() {
281        let config = TimeoutsConfig::default();
282        assert_eq!(config.http_connect_seconds, 5);
283        assert_eq!(config.http_request_seconds, 30);
284        assert_eq!(config.http_download_seconds, 300);
285
286        let resolved = config.http_timeouts();
287        let defaults = vtcode_commons::http::HttpTimeouts::default();
288        assert_eq!(resolved, defaults);
289    }
290
291    #[test]
292    fn http_timeouts_map_zero_to_disabled() {
293        let config = TimeoutsConfig {
294            http_connect_seconds: 0,
295            http_request_seconds: 0,
296            http_download_seconds: 0,
297            ..Default::default()
298        };
299        assert!(config.validate().is_ok());
300
301        let resolved = config.http_timeouts();
302        assert_eq!(resolved.connect, None);
303        assert_eq!(resolved.request, None);
304        assert_eq!(resolved.download, None);
305    }
306
307    #[test]
308    fn http_timeouts_reject_sub_second_typos_but_allow_minimum() {
309        for seconds in 1..MIN_HTTP_TIMEOUT_SECONDS {
310            let config = TimeoutsConfig {
311                http_request_seconds: seconds,
312                ..Default::default()
313            };
314            assert!(config.validate().is_err(), "{seconds}s must be rejected as a likely unit mistake");
315        }
316
317        let config = TimeoutsConfig {
318            http_request_seconds: MIN_HTTP_TIMEOUT_SECONDS,
319            ..Default::default()
320        };
321        assert!(config.validate().is_ok());
322    }
323
324    #[test]
325    fn http_env_overrides_back_the_defaults() {
326        // `#[serde(default)]` resolves through the env-aware fns, so an unset
327        // TOML key can still be steered from the environment.
328        let defaults = TimeoutsConfig::default();
329        assert_eq!(defaults.http_request_seconds, 30);
330
331        crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_REQUEST_TIMEOUT_SECS", Some("120"));
332        crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_CONNECT_TIMEOUT_SECS", Some("15"));
333        // A zero override must not silently disable the deadline.
334        crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS", Some("0"));
335
336        let overridden = TimeoutsConfig::default();
337        crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_REQUEST_TIMEOUT_SECS", None);
338        crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_CONNECT_TIMEOUT_SECS", None);
339        crate::env_helpers::test_env_overrides::set("VTCODE_HTTP_DOWNLOAD_TIMEOUT_SECS", None);
340
341        assert_eq!(overridden.http_request_seconds, 120);
342        assert_eq!(overridden.http_connect_seconds, 15);
343        assert_eq!(overridden.http_download_seconds, 300, "a zero env override is ignored");
344    }
345}