diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index e7ef7c1bd..b270e1117 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -513,12 +513,17 @@ async fn acquire_lock(storage_dir: &Path) -> Result { std::fs::create_dir_all(parent) .with_context(|| format!("creating server lock directory {}", parent.display()))?; } - let lock_file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(&lock_path) - .with_context(|| format!("opening server lock file {}", lock_path.display()))?; + let lock_path_for_open = lock_path.clone(); + let lock_file = tokio::task::spawn_blocking(move || { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path_for_open) + }) + .await + .context("lock-file open task failed")? + .with_context(|| format!("opening server lock file {}", lock_path.display()))?; let poll_interval = Duration::from_millis(50); let timeout = Duration::from_secs(5); diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs index 892e258eb..15fbc17b5 100644 --- a/lib/crates/fabro-sandbox/src/local.rs +++ b/lib/crates/fabro-sandbox/src/local.rs @@ -197,9 +197,13 @@ impl Sandbox for LocalSandbox { let full_path = self.resolve_path(path); let max_depth = depth.unwrap_or(1); - let mut entries = Vec::new(); - list_recursive(&full_path, "", 0, max_depth, &mut entries)?; - Ok(entries) + tokio::task::spawn_blocking(move || { + let mut entries = Vec::new(); + list_recursive(&full_path, "", 0, max_depth, &mut entries)?; + Ok(entries) + }) + .await + .map_err(|e| format!("list_directory task failed: {e}"))? } async fn exec_command( diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index c6c5b88f7..38837784f 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -310,8 +310,8 @@ impl TryFrom for GitHubAppOwner { } } -pub fn build_install_router(state: InstallAppState) -> Router { - static_files::assert_install_mode_shell_ready(); +pub async fn build_install_router(state: InstallAppState) -> Router { + static_files::assert_install_mode_shell_ready().await; Router::new() .route("/health", get(health)) @@ -346,7 +346,7 @@ pub fn build_install_router(state: InstallAppState) -> Router { Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response()) } else if matches!(req.method(), &Method::GET | &Method::HEAD) { let headers = req.headers().clone(); - Ok::<_, Infallible>(static_files::serve_install(&path, &headers)) + Ok::<_, Infallible>(static_files::serve_install(&path, &headers).await) } else { Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response()) } @@ -397,7 +397,7 @@ where let bound_listener = bind_install_listener(&bind_request).await?; state.set_install_bind(&bound_listener.bind); let state = state.with_finish_callback(finish_callback); - let router = build_install_router(state); + let router = build_install_router(state).await; let bind = bound_listener.bind.clone(); on_ready(&bind)?; @@ -956,7 +956,7 @@ async fn post_install_finish( } async fn render_install_shell(headers: HeaderMap, uri: OriginalUri) -> Response { - static_files::serve_install(uri.path(), &headers) + static_files::serve_install(uri.path(), &headers).await } fn token_is_valid(state: &InstallAppState, headers: &HeaderMap, query_token: Option<&str>) -> bool { diff --git a/lib/crates/fabro-server/src/ip_allowlist.rs b/lib/crates/fabro-server/src/ip_allowlist.rs index 0d8f0956b..6ea4cd0c0 100644 --- a/lib/crates/fabro-server/src/ip_allowlist.rs +++ b/lib/crates/fabro-server/src/ip_allowlist.rs @@ -69,7 +69,7 @@ impl GitHubMetaResolver { } async fn resolve_hooks(&self) -> Result> { - let cached = self.load_cache()?; + let cached = self.load_cache().await?; let mut request = self .client .get(&self.meta_url) @@ -124,12 +124,13 @@ impl GitHubMetaResolver { self.store_cache(&GitHubMetaCache { etag, hooks: payload.hooks, - })?; + }) + .await?; Ok(hooks) } - fn load_cache(&self) -> Result> { - match std::fs::read(&self.cache_path) { + async fn load_cache(&self) -> Result> { + match tokio::fs::read(&self.cache_path).await { Ok(contents) => match serde_json::from_slice(&contents) { Ok(cache) => Ok(Some(cache)), Err(error) => { @@ -148,14 +149,16 @@ impl GitHubMetaResolver { } } - fn store_cache(&self, cache: &GitHubMetaCache) -> Result<()> { + async fn store_cache(&self, cache: &GitHubMetaCache) -> Result<()> { if let Some(parent) = self.cache_path.parent() { - std::fs::create_dir_all(parent) + tokio::fs::create_dir_all(parent) + .await .with_context(|| format!("creating {}", parent.display()))?; } let contents = serde_json::to_vec(cache).context("serializing GitHub meta cache")?; - std::fs::write(&self.cache_path, contents) + tokio::fs::write(&self.cache_path, contents) + .await .with_context(|| format!("writing {}", self.cache_path.display()))?; Ok(()) } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 81fe5bebe..b4c24e5dc 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -980,7 +980,7 @@ pub fn build_router_with_options( && matches!(req.method(), &Method::GET | &Method::HEAD) { let headers = req.headers().clone(); - Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers)) + Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers).await) } else { Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response()) } @@ -4460,7 +4460,21 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { run_store.subscribe(), )); - let mut child = match worker_command(state.as_ref(), run_id, execution_mode, &run_dir) + let state_for_build = Arc::clone(&state); + let run_dir_for_build = run_dir.clone(); + let build_cmd_result = tokio::task::spawn_blocking(move || { + worker_command( + state_for_build.as_ref(), + run_id, + execution_mode, + &run_dir_for_build, + ) + }) + .await; + + let mut child = match build_cmd_result + .map_err(|err| anyhow::anyhow!("worker_command task failed: {err}")) + .and_then(|inner| inner) .and_then(|mut cmd| cmd.spawn().context("spawning run worker process")) { Ok(child) => child, diff --git a/lib/crates/fabro-server/src/static_files.rs b/lib/crates/fabro-server/src/static_files.rs index e465a272a..9ea44e2f4 100644 --- a/lib/crates/fabro-server/src/static_files.rs +++ b/lib/crates/fabro-server/src/static_files.rs @@ -7,18 +7,21 @@ use axum::response::{IntoResponse, Response}; const INSTALL_MODE_MARKER: &str = "__FABRO_MODE__ = \"install\""; -pub fn serve(path: &str, headers: &HeaderMap) -> Response { - serve_with_mode(path, headers, SpaMode::Normal) +pub async fn serve(path: &str, headers: &HeaderMap) -> Response { + serve_with_mode(path, headers, SpaMode::Normal).await } -pub fn serve_install(path: &str, headers: &HeaderMap) -> Response { - serve_with_mode(path, headers, SpaMode::Install) +pub async fn serve_install(path: &str, headers: &HeaderMap) -> Response { + serve_with_mode(path, headers, SpaMode::Install).await } -pub(crate) fn assert_install_mode_shell_ready() { - let shell = cached_install_mode_shell().clone().unwrap_or_else(|| { - load_injected_install_shell().expect("install-mode SPA shell asset missing") - }); +pub(crate) async fn assert_install_mode_shell_ready() { + let shell = match cached_install_mode_shell().await { + Some(shell) => shell, + None => load_injected_install_shell() + .await + .expect("install-mode SPA shell asset missing"), + }; let html = String::from_utf8(shell).expect("install-mode SPA shell must be valid UTF-8"); assert!( html.contains(INSTALL_MODE_MARKER), @@ -26,17 +29,21 @@ pub(crate) fn assert_install_mode_shell_ready() { ); } -fn cached_install_mode_shell() -> Option> { +async fn cached_install_mode_shell() -> Option> { static SHELL: OnceLock>> = OnceLock::new(); if cfg!(debug_assertions) { // In debug builds the SPA is reloaded from disk on every request. - return load_injected_install_shell(); + return load_injected_install_shell().await; } - SHELL.get_or_init(load_injected_install_shell).clone() + if let Some(cached) = SHELL.get() { + return cached.clone(); + } + let loaded = load_injected_install_shell().await; + SHELL.get_or_init(|| loaded).clone() } -fn load_injected_install_shell() -> Option> { - Some(inject_install_mode(load_asset("index.html")?)) +async fn load_injected_install_shell() -> Option> { + Some(inject_install_mode(load_asset("index.html").await?)) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -45,14 +52,14 @@ enum SpaMode { Install, } -fn serve_with_mode(path: &str, headers: &HeaderMap, mode: SpaMode) -> Response { +async fn serve_with_mode(path: &str, headers: &HeaderMap, mode: SpaMode) -> Response { let normalized = normalize(path); if is_source_map(&normalized) { return (StatusCode::NOT_FOUND, "Static asset not found").into_response(); } - if let Some(asset) = load_asset_for_mode(&normalized, mode) { + if let Some(asset) = load_asset_for_mode(&normalized, mode).await { return asset_response(&normalized, asset); } @@ -61,7 +68,7 @@ fn serve_with_mode(path: &str, headers: &HeaderMap, mode: SpaMode) -> Response { // `Accept: */*`, and similar non-HTML clients get a 404 so typos // don't silently return 25KB of UI shell. if accepts_html(headers) { - if let Some(index) = load_asset_for_mode("index.html", mode) { + if let Some(index) = load_asset_for_mode("index.html", mode).await { return asset_response("index.html", index); } } @@ -92,9 +99,9 @@ fn normalize(path: &str) -> String { } } -fn load_asset(path: &str) -> Option> { +async fn load_asset(path: &str) -> Option> { if cfg!(debug_assertions) { - if let Some(bytes) = read_disk_asset(path) { + if let Some(bytes) = read_disk_asset(path).await { return Some(bytes); } } @@ -102,11 +109,11 @@ fn load_asset(path: &str) -> Option> { fabro_spa::get(path).map(fabro_spa::AssetBytes::into_vec) } -fn load_asset_for_mode(path: &str, mode: SpaMode) -> Option> { +async fn load_asset_for_mode(path: &str, mode: SpaMode) -> Option> { if mode == SpaMode::Install && path == "index.html" { - return cached_install_mode_shell(); + return cached_install_mode_shell().await; } - load_asset(path) + load_asset(path).await } fn inject_install_mode(bytes: Vec) -> Vec { @@ -129,14 +136,14 @@ fn inject_install_mode(bytes: Vec) -> Vec { injected.into_bytes() } -fn read_disk_asset(path: &str) -> Option> { - read_disk_asset_from_root(&disk_asset_root(), path) +async fn read_disk_asset(path: &str) -> Option> { + read_disk_asset_from_root(&disk_asset_root(), path).await } -fn read_disk_asset_from_root(root: &Path, path: &str) -> Option> { +async fn read_disk_asset_from_root(root: &Path, path: &str) -> Option> { let candidate = root.join(path); if candidate.is_file() { - std::fs::read(candidate).ok() + tokio::fs::read(candidate).await.ok() } else { None } @@ -236,14 +243,16 @@ mod tests { assert_eq!(cache_control("index.html"), "no-cache"); } - #[test] - fn disk_assets_are_loaded_from_explicit_root() { + #[tokio::test] + async fn disk_assets_are_loaded_from_explicit_root() { let temp_dir = tempfile::tempdir().unwrap(); let asset_path = temp_dir.path().join("assets/override.txt"); std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap(); std::fs::write(&asset_path, b"override").unwrap(); - let bytes = read_disk_asset_from_root(temp_dir.path(), "assets/override.txt").unwrap(); + let bytes = read_disk_asset_from_root(temp_dir.path(), "assets/override.txt") + .await + .unwrap(); assert_eq!(bytes, b"override"); } diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index e1aaa7c56..559916c5b 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -70,7 +70,7 @@ async fn configure_token_install(app: &axum::Router, token: &str) { #[tokio::test] async fn install_router_isolated_from_normal_api_surface() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let health_response = app .clone() @@ -128,7 +128,7 @@ async fn install_router_isolated_from_normal_api_surface() { #[tokio::test] async fn install_session_requires_valid_install_token() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let unauthorized = app .clone() @@ -166,7 +166,7 @@ async fn install_session_requires_valid_install_token() { #[tokio::test] async fn install_endpoints_reject_missing_and_wrong_tokens() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let cases = [ ("GET", "/install/session", None), ( @@ -249,7 +249,7 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() { #[tokio::test] async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let response = app .oneshot( @@ -274,7 +274,8 @@ async fn token_install_finish_persists_settings_env_and_vault() { "test-install-token", temp_dir.path(), &config_path, - )); + )) + .await; let llm_response = app .clone() @@ -394,7 +395,8 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() { .with_finish_callback(Arc::new(move || { callback_flag.store(true, Ordering::Release); })), - ); + ) + .await; configure_token_install(&app, "test-install-token").await; @@ -451,7 +453,8 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() { InstallAppState::for_test("test-install-token") .with_provider_base_url(Provider::Anthropic, format!("{}/v1", llm_mock.url(""))) .with_github_api_base_url(github_mock.url("")), - ); + ) + .await; let llm_response = app .clone() @@ -513,7 +516,8 @@ async fn github_app_manifest_round_trip_updates_install_session() { let app = build_install_router( InstallAppState::for_test("test-install-token") .with_github_api_base_url(github_mock.url("")), - ); + ) + .await; let server_response = app .clone() @@ -618,7 +622,7 @@ async fn github_app_manifest_round_trip_updates_install_session() { #[tokio::test] async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_token_strategy() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let server_response = app .clone() @@ -741,7 +745,8 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s let app = build_install_router( InstallAppState::for_test("test-install-token") .with_github_api_base_url(github_mock.url("")), - ); + ) + .await; let server_response = app .clone() @@ -881,7 +886,8 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin let app = build_install_router( InstallAppState::for_test("test-install-token") .with_github_api_base_url(github_mock.url("")), - ); + ) + .await; let server_response = app .clone() @@ -970,7 +976,7 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin #[tokio::test] async fn install_server_rejects_trailing_slash_canonical_urls() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let response = app .oneshot( @@ -1015,7 +1021,8 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys( .with_finish_callback(Arc::new(move || { callback_flag.store(true, Ordering::Release); })), - ); + ) + .await; configure_token_install(&app, "test-install-token").await; @@ -1104,7 +1111,8 @@ async fn install_finish_failure_leaves_home_dev_token_mirror_written() { let app = build_install_router( InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path) .with_home(home.clone()), - ); + ) + .await; configure_token_install(&app, "test-install-token").await; diff --git a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs b/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs index 9a1bd7214..3a0d9954e 100644 --- a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs +++ b/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs @@ -7,7 +7,7 @@ use crate::helpers::body_json; #[tokio::test] async fn install_llm_endpoints_reject_openai_compatible_in_v1() { - let app = build_install_router(InstallAppState::for_test("test-install-token")); + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; let test_response = app .clone() diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index cf2cce651..40cd93f4c 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -83,7 +83,7 @@ fn request_for(method: &Method, uri: &str) -> Request { async fn all_spec_routes_are_routable() { let spec = load_spec(); let normal_app = build_router(test_app_state(), AuthMode::Disabled); - let install_app = build_install_router(InstallAppState::for_test("test-install-token")); + let install_app = build_install_router(InstallAppState::for_test("test-install-token")).await; let paths = spec .get("paths") @@ -122,7 +122,7 @@ async fn all_spec_routes_are_routable() { async fn install_and_normal_routes_stay_isolated() { let spec = load_spec(); let normal_app = build_router(test_app_state(), AuthMode::Disabled); - let install_app = build_install_router(InstallAppState::for_test("test-install-token")); + let install_app = build_install_router(InstallAppState::for_test("test-install-token")).await; let paths = spec .get("paths") diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs index b9757ca94..908a03e6d 100644 --- a/lib/crates/fabro-workflow/src/artifact.rs +++ b/lib/crates/fabro-workflow/src/artifact.rs @@ -174,7 +174,7 @@ pub async fn sync_artifacts_to_env( } } - let content = std::fs::read_to_string(&local_path).map_err(|e| { + let content = tokio::fs::read_to_string(&local_path).await.map_err(|e| { Error::engine(format!("failed to read local artifact {local_path}: {e}")) })?; @@ -325,7 +325,8 @@ async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result, root: &str) -> Vec std::result::Result { let mime = mime_guess::from_path(relative_path) .first_or_octet_stream() .to_string(); - let data = std::fs::read(local_path) + let data = tokio::fs::read(local_path) + .await .map_err(|e| format!("failed to read {}: {e}", local_path.display()))?; let bytes = u64::try_from(data.len()).unwrap_or(u64::MAX); let content_md5 = format!("{:x}", md5::compute(&data)); @@ -314,7 +315,7 @@ pub async fn collect_artifacts( .download_file_to_local(&file.relative_path, &dest) .await { - Ok(()) => match compute_artifact_info(&file.relative_path, &dest) { + Ok(()) => match compute_artifact_info(&file.relative_path, &dest).await { Ok(info) => { files_copied += 1; total_bytes += info.bytes;