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