feat(web-server): seam shared-core run_embedded_with_core + fix du port effectif (#68 B1)

Ouvre le point d'intégration du serveur embarqué desktop : `run_embedded_with_core`
reçoit un `Arc<BackendCore>` 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<BackendCore>` ; `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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:50:18 +02:00
parent 56b9f5f619
commit b7d38f1d30

View File

@ -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<EmbeddedServerHandle, String> {
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<BackendCore>,
) -> Result<EmbeddedServerHandle, String> {
config.validate()?;
let listener = TcpListener::bind(config.listen)
.await
@ -274,7 +288,9 @@ pub async fn run_embedded(config: ServerConfig) -> Result<EmbeddedServerHandle,
let local_addr = listener
.local_addr()
.map_err(|err| format!("failed to read listener address: {err}"))?;
let state = Arc::new(ServerState::new(config.clone()));
let mut effective_config = config.clone();
effective_config.listen = local_addr;
let state = Arc::new(ServerState::with_core(effective_config, core));
let pairing_code = state.pairing_code().to_owned();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let task = tokio::spawn(run_listener(listener, state, shutdown_rx));
@ -328,7 +344,7 @@ fn validate_web_root(path: PathBuf, source: &str) -> Result<PathBuf, String> {
struct ServerState {
config: ServerConfig,
app: BackendCore,
app: Arc<BackendCore>,
pairing_code: String,
sessions: Mutex<HashSet<String>>,
ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>,
@ -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<BackendCore>) -> 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<dyn SecurityLogger>,
) -> 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<ServerState>,
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::<u16>()
.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<ResponseBody>) -> (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<ServerState>,
project_id: &str,