From b7d38f1d306c434b8e0cfe2acfef6a550a8be363 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 18:50:18 +0200 Subject: [PATCH] feat(web-server): seam shared-core run_embedded_with_core + fix du port effectif (#68 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ouvre le point d'intégration du serveur embarqué desktop : `run_embedded_with_core` reçoit un `Arc` déjà construit, pour que l'adapter HTTP partage le composition root de l'adapter Tauri au lieu d'en bâtir un second dans le même processus. `ServerState` porte désormais `Arc` ; `run_embedded` est conservé en compat et délègue en construisant son propre core. Corrige au passage un vrai bug produit, découvert en rejouant les tests hors sandbox : `run_embedded` ne réconciliait pas le port effectif avec la config. Sur un bind éphémère (`127.0.0.1:0`), l'état du serveur gardait `listen` à port 0, donc `origin_allowed` comparait l'Origin entrante à `http://127.0.0.1:0` et le serveur embarqué rejetait **toutes** les requêtes API en 403. La config reçoit maintenant `local_addr` lu sur le listener **avant** la construction du state. Comble le trou signalé au merge de #65 : `run_embedded().stop()` est enfin couvert. ATTENTION — le premier « 57 passed » de ce lot était un FAUX VERT. Le sandbox d'exécution interdit `TcpListener::bind` ; les tests sortaient en silence sur EPERM, dont précisément le test `stop()`. Les replis EPERM sont supprimés : un bind refusé fait désormais échouer le test au lieu de le peindre en vert. QA ré-exécutée par Git HORS SANDBOX avant merge (sinon on reproduit le faux vert) : `cargo test -p web-server` 57 passed, 0 échec, avec `run_embedded_stop_shuts_down_accept_loop` et `run_embedded_with_core_uses_injected_core_for_http_invokes` réellement exécutés sur le vrai chemin réseau. `-p backend` 41 · `-p app-tauri` 35, 0 échec. DETTE CONNUE, tracée en #72 B0 : le chemin CLI standalone `run_server` garde la même hypothèse fausse — il reçoit un `ServerState` construit AVANT le bind, donc `idea-serve --listen 127.0.0.1:0` reste cassé à l'identique. Co-Authored-By: Claude Opus 4.8 --- crates/web-server/src/lib.rs | 185 +++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 7 deletions(-) diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index cc6eca7..c93db18 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -221,7 +221,7 @@ pub enum EmbeddedServerState { Stopping, } -/// Handle returned by [`run_embedded`]. +/// Handle returned by [`run_embedded`] and [`run_embedded_with_core`]. pub struct EmbeddedServerHandle { url: String, pairing_code: String, @@ -264,9 +264,23 @@ impl EmbeddedServerHandle { /// Starts the web server as an embedded library component. /// -/// This is the integration point for the desktop-hosted server planned after -/// the headless extraction. The returned handle owns shutdown. +/// This compatibility entry point builds its own backend composition root. The +/// desktop app should use [`run_embedded_with_core`] so HTTP handlers share the +/// already-running backend core. pub async fn run_embedded(config: ServerConfig) -> Result { + let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); + run_embedded_with_core(config, core).await +} + +/// Starts the web server with an already-built backend core. +/// +/// This is the integration point for the desktop-hosted server: the HTTP +/// driving adapter receives the same composition root as the Tauri adapter +/// instead of constructing a second backend core in the same process. +pub async fn run_embedded_with_core( + config: ServerConfig, + core: Arc, +) -> Result { config.validate()?; let listener = TcpListener::bind(config.listen) .await @@ -274,7 +288,9 @@ pub async fn run_embedded(config: ServerConfig) -> Result Result { struct ServerState { config: ServerConfig, - app: BackendCore, + app: Arc, pairing_code: String, sessions: Mutex>, ws_pty_bridge: Arc>, @@ -337,8 +353,13 @@ struct ServerState { impl ServerState { fn new(config: ServerConfig) -> Self { + let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); + Self::with_core(config, core) + } + + fn with_core(config: ServerConfig, core: Arc) -> Self { Self { - app: BackendCore::build(config.app_data_dir.clone()), + app: core, config, pairing_code: new_pairing_code(), sessions: Mutex::new(HashSet::new()), @@ -359,7 +380,7 @@ impl ServerState { security_logger: Arc, ) -> Self { Self { - app: BackendCore::build(config.app_data_dir.clone()), + app: Arc::new(BackendCore::build(config.app_data_dir.clone())), config, pairing_code: pairing_code.into(), sessions: Mutex::new(HashSet::new()), @@ -2513,6 +2534,73 @@ mod tests { Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) } + #[tokio::test] + async fn run_embedded_stop_shuts_down_accept_loop() { + let config = ServerConfig { + listen: "127.0.0.1:0".parse().unwrap(), + ..test_config() + }; + + let handle = run_embedded(config).await.unwrap(); + + assert_eq!(handle.state(), EmbeddedServerState::Running); + assert_ne!(handle.url(), "http://127.0.0.1:0"); + handle.stop().await.unwrap(); + } + + #[tokio::test] + async fn run_embedded_with_core_uses_injected_core_for_http_invokes() { + let core = Arc::new(BackendCore::build( + std::env::temp_dir().join(format!("idea-shared-core-{}", Uuid::new_v4())), + )); + let project_id = create_project_on_core(&core, "Shared Core").await; + let config = ServerConfig { + listen: "127.0.0.1:0".parse().unwrap(), + app_data_dir: std::env::temp_dir() + .join(format!("idea-unused-embedded-core-{}", Uuid::new_v4())), + ..test_config() + }; + + let handle = run_embedded_with_core(config, Arc::clone(&core)) + .await + .unwrap(); + let (pair_status, _, pair_headers) = embedded_json_request( + handle.url(), + "/api/pair", + json!({ "code": handle.pairing_code() }), + &[], + ) + .await; + let cookie = pair_headers + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let (invoke_status, body, _) = embedded_json_request( + handle.url(), + "/api/invoke", + json!({ "command": "list_projects", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + handle.stop().await.unwrap(); + + assert_eq!(pair_status, StatusCode::OK); + assert_eq!(invoke_status, StatusCode::OK); + let projects = body + .as_array() + .expect("ProjectListDto is a transparent array"); + let project = projects + .iter() + .find(|project| project["id"] == project_id) + .expect("project created on injected core is listed"); + assert_eq!(project["name"], "Shared Core"); + } + async fn request( state: Arc, method: Method, @@ -2549,6 +2637,68 @@ mod tests { (status, value, headers) } + async fn embedded_json_request( + base_url: &str, + path: &str, + body: Value, + extra_headers: &[(&str, &str)], + ) -> (StatusCode, Value, HeaderMap) { + let addr = base_url + .strip_prefix("http://") + .expect("embedded server URL is HTTP"); + let body = serde_json::to_vec(&body).unwrap(); + let mut request = format!( + "POST {path} HTTP/1.1\r\n\ + host: {addr}\r\n\ + origin: {base_url}\r\n\ + content-type: application/json\r\n\ + content-length: {}\r\n", + body.len() + ) + .into_bytes(); + for (name, value) in extra_headers { + request.extend_from_slice(name.as_bytes()); + request.extend_from_slice(b": "); + request.extend_from_slice(value.as_bytes()); + request.extend_from_slice(b"\r\n"); + } + request.extend_from_slice(b"\r\n"); + request.extend_from_slice(&body); + + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + stream.write_all(&request).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + + let header_end = find_header_end(&response).expect("response has headers"); + let head = std::str::from_utf8(&response[..header_end]).unwrap(); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap(); + let status_code = status_line + .split_whitespace() + .nth(1) + .unwrap() + .parse::() + .unwrap(); + let mut headers = HeaderMap::new(); + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + headers.insert( + HeaderName::from_bytes(name.trim().as_bytes()).unwrap(), + HeaderValue::from_str(value.trim()).unwrap(), + ); + } + let body = &response[header_end + 4..]; + let value = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(body).unwrap() + }; + (StatusCode::from_u16(status_code).unwrap(), value, headers) + } + async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { let status = response.status(); let headers = response.headers().clone(); @@ -2596,6 +2746,27 @@ mod tests { output.project.id.to_string() } + async fn create_project_on_core(core: &BackendCore, name: &str) -> String { + let root = std::env::temp_dir() + .join(format!( + "idea-server-shared-core-project-{}", + Uuid::new_v4() + )) + .to_string_lossy() + .into_owned(); + let output = core + .create_project + .execute(CreateProjectInput { + name: name.to_owned(), + root, + remote: None, + default_profile_id: None, + }) + .await + .expect("test project is created on injected core"); + output.project.id.to_string() + } + async fn create_background_task_for_test( state: &Arc, project_id: &str,