use std::net::TcpListener; use std::path::Path; use std::process::Stdio; use std::time::Duration; use serde_json::{Value, json}; use tokio::process::{Child, Command}; struct GatewayProcess(Child); impl Drop for GatewayProcess { fn drop(&mut self) { let _ = self.0.start_kill(); } } fn available_port() -> u16 { TcpListener::bind("127.0.0.1:0") .unwrap() .local_addr() .unwrap() .port() } fn config(workspace: &Path, model_id: &str) -> Value { json!({ "providers": { "provider": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test" } }, "models": { "model": { "model_id": model_id } }, "agents": { "default": { "provider": "provider", "model": "model" } }, "gateway": { "require_pairing": false }, "workspace_dir": workspace }) } async fn wait_for_health(client: &reqwest::Client, base: &str) { for _ in 0..100 { if client .get(format!("{base}/health")) .send() .await .is_ok_and(|response| response.status().is_success()) { return; } tokio::time::sleep(Duration::from_millis(50)).await; } panic!("gateway did not become healthy"); } #[tokio::test] async fn gateway_reloads_valid_config_and_keeps_serving_after_invalid_config() { let temp = tempfile::tempdir().unwrap(); let home = temp.path().join("home"); let config_dir = home.join(".picobot"); let workspace = temp.path().join("workspace"); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::create_dir_all(&workspace).unwrap(); let config_path = config_dir.join("config.json"); std::fs::write( &config_path, serde_json::to_vec_pretty(&config(&workspace, "old-model")).unwrap(), ) .unwrap(); let port = available_port(); let child = Command::new(env!("CARGO_BIN_EXE_picobot")) .args([ "gateway", "--host", "127.0.0.1", "--port", &port.to_string(), ]) .env("HOME", &home) .stdout(Stdio::null()) .stderr(Stdio::null()) .kill_on_drop(true) .spawn() .unwrap(); let mut gateway = GatewayProcess(child); let client = reqwest::Client::new(); let base = format!("http://127.0.0.1:{port}"); wait_for_health(&client, &base).await; std::fs::write( &config_path, serde_json::to_vec_pretty(&config(&workspace, "new-model")).unwrap(), ) .unwrap(); let accepted: Value = client .post(format!("{base}/api/config/reload")) .send() .await .unwrap() .error_for_status() .unwrap() .json() .await .unwrap(); assert_eq!(accepted["generation"], 2); let mut active = false; for _ in 0..100 { let response = client .get(format!("{base}/api/config/reload/status")) .send() .await; if let Ok(response) = response && let Ok(status) = response.json::().await && status["generation"] == 2 && status["phase"] == "active" { active = true; break; } tokio::time::sleep(Duration::from_millis(50)).await; } assert!(active, "reloaded generation did not become active"); std::fs::write(&config_path, b"{").unwrap(); let invalid = client .post(format!("{base}/api/config/reload")) .send() .await .unwrap(); assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST); assert!( client .get(format!("{base}/health")) .send() .await .unwrap() .status() .is_success(), "invalid candidate stopped the active generation" ); gateway.0.start_kill().unwrap(); gateway.0.wait().await.unwrap(); }