fix: replace unsafe Arc mutation in update_budget with RwLock

PUT /api/budget casts &Arc<AppState> to *mut KernelConfig and mutates
the budget fields through a raw pointer. This is unsound: AppState is
shared across Tokio worker threads, so two concurrent PUT /api/budget
requests cause a data race on the same memory location.

Replace with Arc<tokio::sync::RwLock<BudgetConfig>> stored on AppState,
initialized from kernel.config.budget at startup. All readers use
.read().await and all writers use .write().await. No unsafe code remains
in the budget update path.

Fixes: data race / undefined behaviour under concurrent budget updates
This commit is contained in:
rager306
2026-03-22 16:16:09 +07:00
parent db86ff4ce3
commit b3787e07ea
2 changed files with 15 additions and 21 deletions
+14 -21
View File
@@ -40,6 +40,9 @@ pub struct AppState {
/// Avoids blocking the `/api/providers` endpoint on TCP timeouts to
/// unreachable local services. 60-second TTL.
pub provider_probe_cache: openfang_runtime::provider_health::ProbeCache,
/// Thread-safe mutable budget config. Updated via PUT /api/budget.
/// Initialized from `kernel.config.budget` at startup.
pub budget_config: Arc<tokio::sync::RwLock<openfang_types::config::BudgetConfig>>,
}
/// POST /api/agents — Spawn a new agent.
@@ -5266,10 +5269,8 @@ pub async fn usage_daily(State(state): State<Arc<AppState>>) -> impl IntoRespons
/// GET /api/budget — Current budget status (limits, spend, % used).
pub async fn budget_status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let status = state
.kernel
.metering
.budget_status(&state.kernel.config.budget);
let budget = state.budget_config.read().await;
let status = state.kernel.metering.budget_status(&budget);
Json(serde_json::to_value(&status).unwrap_or_default())
}
@@ -5278,34 +5279,26 @@ pub async fn update_budget(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// SAFETY: Budget config is updated in-place. Since KernelConfig is behind
// an Arc and we only have &self, we use ptr mutation (same pattern as OFP).
let config_ptr = &state.kernel.config as *const openfang_types::config::KernelConfig
as *mut openfang_types::config::KernelConfig;
// Apply updates
unsafe {
{
let mut budget = state.budget_config.write().await;
if let Some(v) = body["max_hourly_usd"].as_f64() {
(*config_ptr).budget.max_hourly_usd = v;
budget.max_hourly_usd = v;
}
if let Some(v) = body["max_daily_usd"].as_f64() {
(*config_ptr).budget.max_daily_usd = v;
budget.max_daily_usd = v;
}
if let Some(v) = body["max_monthly_usd"].as_f64() {
(*config_ptr).budget.max_monthly_usd = v;
budget.max_monthly_usd = v;
}
if let Some(v) = body["alert_threshold"].as_f64() {
(*config_ptr).budget.alert_threshold = v.clamp(0.0, 1.0);
budget.alert_threshold = v.clamp(0.0, 1.0);
}
if let Some(v) = body["default_max_llm_tokens_per_hour"].as_u64() {
(*config_ptr).budget.default_max_llm_tokens_per_hour = v;
budget.default_max_llm_tokens_per_hour = v;
}
}
let status = state
.kernel
.metering
.budget_status(&state.kernel.config.budget);
let budget = state.budget_config.read().await;
let status = state.kernel.metering.budget_status(&budget);
Json(serde_json::to_value(&status).unwrap_or_default())
}
+1
View File
@@ -51,6 +51,7 @@ pub async fn build_router(
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
});
// CORS: allow localhost origins by default. If API key is set, the API