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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user