Skip to content

Commit d5eeb2f

Browse files
committed
Add per-probe runtime deadline
1 parent f415ddd commit d5eeb2f

2 files changed

Lines changed: 224 additions & 15 deletions

File tree

src-tauri/src/plugin_engine/host_api.rs

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
use aes_gcm::{
2-
AesGcm, Nonce,
3-
aead::{Aead, KeyInit, OsRng, generic_array::typenum::U16, rand_core::RngCore},
2+
aead::{generic_array::typenum::U16, rand_core::RngCore, Aead, KeyInit, OsRng},
43
aes::Aes256,
4+
AesGcm, Nonce,
55
};
6-
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
6+
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
77
use rquickjs::{Ctx, Exception, Function, Object};
88
use sha2::{Digest, Sha256};
99
use std::collections::{HashMap, HashSet};
1010
use std::ffi::{OsStr, OsString};
1111
use std::path::{Path, PathBuf};
1212
use std::process::Command;
1313
use std::sync::{Mutex, OnceLock};
14+
use std::time::{Duration, Instant};
1415

1516
const WHITELISTED_ENV_VARS: [&str; 16] = [
1617
"CODEX_HOME",
@@ -31,6 +32,45 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [
3132
"PI_CODING_AGENT_DIR",
3233
];
3334

35+
#[derive(Clone, Copy, Debug)]
36+
pub(crate) struct ProbeDeadline {
37+
expires_at: Option<Instant>,
38+
}
39+
40+
impl ProbeDeadline {
41+
#[cfg(test)]
42+
pub(crate) fn none() -> Self {
43+
Self { expires_at: None }
44+
}
45+
46+
pub(crate) fn at(expires_at: Instant) -> Self {
47+
Self {
48+
expires_at: Some(expires_at),
49+
}
50+
}
51+
52+
pub(crate) fn has_elapsed(self) -> bool {
53+
self.expires_at
54+
.map(|expires_at| Instant::now() >= expires_at)
55+
.unwrap_or(false)
56+
}
57+
58+
fn clamp_duration(self, requested: Duration) -> Duration {
59+
let Some(expires_at) = self.expires_at else {
60+
return requested;
61+
};
62+
let remaining = expires_at
63+
.checked_duration_since(Instant::now())
64+
.unwrap_or_default();
65+
let clamped = requested.min(remaining);
66+
if clamped.is_zero() {
67+
Duration::from_millis(1)
68+
} else {
69+
clamped
70+
}
71+
}
72+
}
73+
3474
fn last_non_empty_trimmed_line(text: &str) -> Option<String> {
3575
text.lines()
3676
.map(|line| line.trim())
@@ -511,11 +551,28 @@ fn encrypt_aes_256_gcm_envelope(plaintext: &str, key_b64: &str) -> Result<String
511551
))
512552
}
513553

514-
pub fn inject_host_api<'js>(
554+
#[cfg(test)]
555+
pub(crate) fn inject_host_api<'js>(
556+
ctx: &Ctx<'js>,
557+
plugin_id: &str,
558+
app_data_dir: &PathBuf,
559+
app_version: &str,
560+
) -> rquickjs::Result<()> {
561+
inject_host_api_with_deadline(
562+
ctx,
563+
plugin_id,
564+
app_data_dir,
565+
app_version,
566+
ProbeDeadline::none(),
567+
)
568+
}
569+
570+
pub(crate) fn inject_host_api_with_deadline<'js>(
515571
ctx: &Ctx<'js>,
516572
plugin_id: &str,
517573
app_data_dir: &PathBuf,
518574
app_version: &str,
575+
deadline: ProbeDeadline,
519576
) -> rquickjs::Result<()> {
520577
let globals = ctx.globals();
521578
let probe_ctx = Object::new(ctx.clone())?;
@@ -545,11 +602,11 @@ pub fn inject_host_api<'js>(
545602
inject_fs(ctx, &host)?;
546603
inject_crypto(ctx, &host)?;
547604
inject_env(ctx, &host, plugin_id)?;
548-
inject_http(ctx, &host, plugin_id)?;
605+
inject_http(ctx, &host, plugin_id, deadline)?;
549606
inject_keychain(ctx, &host, plugin_id)?;
550607
inject_sqlite(ctx, &host)?;
551608
inject_ls(ctx, &host, plugin_id)?;
552-
inject_ccusage(ctx, &host, plugin_id)?;
609+
inject_ccusage(ctx, &host, plugin_id, deadline)?;
553610

554611
probe_ctx.set("host", host)?;
555612
globals.set("__openusage_ctx", probe_ctx)?;
@@ -720,7 +777,12 @@ fn inject_env<'js>(ctx: &Ctx<'js>, host: &Object<'js>, _plugin_id: &str) -> rqui
720777
Ok(())
721778
}
722779

723-
fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> {
780+
fn inject_http<'js>(
781+
ctx: &Ctx<'js>,
782+
host: &Object<'js>,
783+
plugin_id: &str,
784+
deadline: ProbeDeadline,
785+
) -> rquickjs::Result<()> {
724786
let http_obj = Object::new(ctx.clone())?;
725787
let pid = plugin_id.to_string();
726788

@@ -733,6 +795,10 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rqui
733795
Exception::throw_message(&ctx_inner, &format!("invalid request: {}", e))
734796
})?;
735797

798+
if deadline.has_elapsed() {
799+
return Err(Exception::throw_message(&ctx_inner, "probe timed out"));
800+
}
801+
736802
let method_str = req.method.as_deref().unwrap_or("GET");
737803
let redacted_url = redact_url(&req.url);
738804
log::info!("[plugin:{}] HTTP {} {}", pid, method_str, redacted_url);
@@ -758,9 +824,10 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rqui
758824
}
759825

760826
let timeout_ms = req.timeout_ms.unwrap_or(10_000);
827+
let timeout = deadline.clamp_duration(Duration::from_millis(timeout_ms));
761828
let mut builder = reqwest::blocking::Client::builder()
762-
.timeout(std::time::Duration::from_millis(timeout_ms))
763-
.connect_timeout(std::time::Duration::from_millis(timeout_ms))
829+
.timeout(timeout)
830+
.connect_timeout(timeout)
764831
.redirect(reqwest::redirect::Policy::none());
765832

766833
// Apply pre-resolved proxy (localhost bypass already configured)
@@ -1972,31 +2039,56 @@ fn format_ccusage_timeout(timeout: std::time::Duration) -> String {
19722039
format!("{:.3}s", timeout.as_secs_f64())
19732040
}
19742041

2042+
#[cfg(test)]
19752043
fn run_ccusage_with_runner(
19762044
kind: CcusageRunnerKind,
19772045
program: &str,
19782046
opts: &CcusageQueryOpts,
19792047
provider: CcusageProvider,
19802048
plugin_id: &str,
19812049
) -> CcusageRunnerResult {
2050+
run_ccusage_with_runner_deadline(
2051+
kind,
2052+
program,
2053+
opts,
2054+
provider,
2055+
plugin_id,
2056+
ProbeDeadline::none(),
2057+
)
2058+
}
2059+
2060+
fn run_ccusage_with_runner_deadline(
2061+
kind: CcusageRunnerKind,
2062+
program: &str,
2063+
opts: &CcusageQueryOpts,
2064+
provider: CcusageProvider,
2065+
plugin_id: &str,
2066+
deadline: ProbeDeadline,
2067+
) -> CcusageRunnerResult {
2068+
if deadline.has_elapsed() {
2069+
log::warn!("[plugin:{}] ccusage skipped: probe timed out", plugin_id);
2070+
return CcusageRunnerResult::TimedOut;
2071+
}
2072+
19822073
let current = run_ccusage_with_runner_timeout(
19832074
kind,
19842075
program,
19852076
opts,
19862077
provider,
19872078
plugin_id,
19882079
CcusageCommandFlavor::Current,
1989-
std::time::Duration::from_secs(CCUSAGE_TIMEOUT_SECS),
2080+
deadline.clamp_duration(Duration::from_secs(CCUSAGE_TIMEOUT_SECS)),
19902081
);
19912082
match current {
2083+
CcusageRunnerResult::Failed if deadline.has_elapsed() => CcusageRunnerResult::TimedOut,
19922084
CcusageRunnerResult::Failed => run_ccusage_with_runner_timeout(
19932085
kind,
19942086
program,
19952087
opts,
19962088
provider,
19972089
plugin_id,
19982090
CcusageCommandFlavor::Legacy,
1999-
std::time::Duration::from_secs(CCUSAGE_TIMEOUT_SECS),
2091+
deadline.clamp_duration(Duration::from_secs(CCUSAGE_TIMEOUT_SECS)),
20002092
),
20012093
other => other,
20022094
}
@@ -2195,6 +2287,7 @@ fn inject_ccusage<'js>(
21952287
ctx: &Ctx<'js>,
21962288
host: &Object<'js>,
21972289
plugin_id: &str,
2290+
deadline: ProbeDeadline,
21982291
) -> rquickjs::Result<()> {
21992292
let ccusage_obj = Object::new(ctx.clone())?;
22002293
let pid = plugin_id.to_string();
@@ -2222,7 +2315,11 @@ fn inject_ccusage<'js>(
22222315
&opts,
22232316
provider,
22242317
&pid,
2225-
run_ccusage_with_runner,
2318+
|kind, program, opts, provider, plugin_id| {
2319+
run_ccusage_with_runner_deadline(
2320+
kind, program, opts, provider, plugin_id, deadline,
2321+
)
2322+
},
22262323
))
22272324
},
22282325
)?,
@@ -4087,6 +4184,21 @@ esac
40874184
);
40884185
}
40894186

4187+
#[test]
4188+
fn probe_deadline_clamps_host_timeout_to_remaining_budget() {
4189+
let deadline = ProbeDeadline::at(Instant::now() + Duration::from_millis(25));
4190+
let clamped = deadline.clamp_duration(Duration::from_secs(10));
4191+
4192+
assert!(
4193+
clamped <= Duration::from_millis(25),
4194+
"host timeout should not exceed remaining probe budget"
4195+
);
4196+
assert!(
4197+
clamped >= Duration::from_millis(1),
4198+
"host timeout should stay non-zero for blocking clients"
4199+
);
4200+
}
4201+
40904202
#[cfg(unix)]
40914203
#[test]
40924204
fn ccusage_timeout_kills_descendant_and_closes_pipes() {

0 commit comments

Comments
 (0)