Merge feature/ticket68-embedded-server-core into develop (#68 B1)

Livre la fondation backend du serveur embarqué desktop : le seam
`run_embedded_with_core(config, Arc<BackendCore>)`, qui permet à l'app Tauri de
faire tourner le serveur web en partageant son composition root plutôt qu'en
dupliquant un backend dans le même processus.

Apporte aussi le fix du port effectif : un serveur embarqué sur port éphémère
rejetait toutes les requêtes API en 403 (origine comparée à un `listen` resté à
port 0). Et comble le trou de couverture sur `run_embedded().stop()` signalé au
merge de #65.

Vert avant merge, ré-exécuté par Git HORS SANDBOX — impératif ici : le sandbox
interdit `TcpListener::bind` et avait produit un faux vert sur ce lot précis.
`cargo test -p web-server` 57 passed (les 2 tests embedded réellement exécutés) ·
`-p backend` 41 · `-p app-tauri` 35, 0 échec.

Étape 1 du plan d'intégration séquentiel. Suit : #72 (durcissement bind/origine/
proxy, dont B0 = le même bug de port sur le chemin CLI `run_server`), puis #71
lot 1 (diagnostics), puis le Settings Deployment de #68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:50:37 +02:00

View File

@ -221,7 +221,7 @@ pub enum EmbeddedServerState {
Stopping, Stopping,
} }
/// Handle returned by [`run_embedded`]. /// Handle returned by [`run_embedded`] and [`run_embedded_with_core`].
pub struct EmbeddedServerHandle { pub struct EmbeddedServerHandle {
url: String, url: String,
pairing_code: String, pairing_code: String,
@ -264,9 +264,23 @@ impl EmbeddedServerHandle {
/// Starts the web server as an embedded library component. /// Starts the web server as an embedded library component.
/// ///
/// This is the integration point for the desktop-hosted server planned after /// This compatibility entry point builds its own backend composition root. The
/// the headless extraction. The returned handle owns shutdown. /// 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> { 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()?; config.validate()?;
let listener = TcpListener::bind(config.listen) let listener = TcpListener::bind(config.listen)
.await .await
@ -274,7 +288,9 @@ pub async fn run_embedded(config: ServerConfig) -> Result<EmbeddedServerHandle,
let local_addr = listener let local_addr = listener
.local_addr() .local_addr()
.map_err(|err| format!("failed to read listener address: {err}"))?; .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 pairing_code = state.pairing_code().to_owned();
let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel();
let task = tokio::spawn(run_listener(listener, state, shutdown_rx)); 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 { struct ServerState {
config: ServerConfig, config: ServerConfig,
app: BackendCore, app: Arc<BackendCore>,
pairing_code: String, pairing_code: String,
sessions: Mutex<HashSet<String>>, sessions: Mutex<HashSet<String>>,
ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>, ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>,
@ -337,8 +353,13 @@ struct ServerState {
impl ServerState { impl ServerState {
fn new(config: ServerConfig) -> Self { 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 { Self {
app: BackendCore::build(config.app_data_dir.clone()), app: core,
config, config,
pairing_code: new_pairing_code(), pairing_code: new_pairing_code(),
sessions: Mutex::new(HashSet::new()), sessions: Mutex::new(HashSet::new()),
@ -359,7 +380,7 @@ impl ServerState {
security_logger: Arc<dyn SecurityLogger>, security_logger: Arc<dyn SecurityLogger>,
) -> Self { ) -> Self {
Self { Self {
app: BackendCore::build(config.app_data_dir.clone()), app: Arc::new(BackendCore::build(config.app_data_dir.clone())),
config, config,
pairing_code: pairing_code.into(), pairing_code: pairing_code.into(),
sessions: Mutex::new(HashSet::new()), sessions: Mutex::new(HashSet::new()),
@ -2513,6 +2534,73 @@ mod tests {
Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) 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( async fn request(
state: Arc<ServerState>, state: Arc<ServerState>,
method: Method, method: Method,
@ -2549,6 +2637,68 @@ mod tests {
(status, value, headers) (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) { async fn response_bytes(response: Response<ResponseBody>) -> (StatusCode, Bytes, HeaderMap) {
let status = response.status(); let status = response.status();
let headers = response.headers().clone(); let headers = response.headers().clone();
@ -2596,6 +2746,27 @@ mod tests {
output.project.id.to_string() 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( async fn create_background_task_for_test(
state: &Arc<ServerState>, state: &Arc<ServerState>,
project_id: &str, project_id: &str,