diff --git a/crates/tower-runtime/src/errors.rs b/crates/tower-runtime/src/errors.rs index 19c8ed10..eae04338 100644 --- a/crates/tower-runtime/src/errors.rs +++ b/crates/tower-runtime/src/errors.rs @@ -64,6 +64,9 @@ pub enum Error { #[snafu(display("cancelled"))] Cancelled, + + #[snafu(display("dependency installation failed"))] + DependencyInstallationFailed, } impl From for Error { diff --git a/crates/tower-runtime/src/local.rs b/crates/tower-runtime/src/local.rs index ee4968e2..a6296517 100644 --- a/crates/tower-runtime/src/local.rs +++ b/crates/tower-runtime/src/local.rs @@ -222,7 +222,13 @@ async fn execute_local_app( )); // Wait for venv to finish up. - wait_for_process(ctx.clone(), &cancel_token, child).await; + let res = wait_for_process(ctx.clone(), &cancel_token, child).await; + + if res != 0 { + // If the venv process failed, we want to return an error. + let _ = sx.send(res); + return Err(Error::VirtualEnvCreationFailed); + } // Check once more if the process was cancelled before we do a uv sync. The sync itself, // once started, will take a while and we have logic for checking for cancellation. @@ -269,7 +275,13 @@ async fn execute_local_app( )); // Let's wait for the setup to finish. We don't care about the results. - wait_for_process(ctx.clone(), &cancel_token, child).await; + let res = wait_for_process(ctx.clone(), &cancel_token, child).await; + + if res != 0 { + // If the sync process failed, we want to return an error. + let _ = sx.send(res); + return Err(Error::DependencyInstallationFailed); + } } } diff --git a/crates/tower-runtime/tests/example-apps/05-broken-dependencies/README.md b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/README.md new file mode 100644 index 00000000..e69de29b diff --git a/crates/tower-runtime/tests/example-apps/05-broken-dependencies/Towerfile b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/Towerfile new file mode 100644 index 00000000..0f5b958c --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/Towerfile @@ -0,0 +1,3 @@ +[app] +name = "05-broken-dependencies" +script = "./main.py" diff --git a/crates/tower-runtime/tests/example-apps/05-broken-dependencies/main.py b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/main.py new file mode 100644 index 00000000..0efff4c8 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/main.py @@ -0,0 +1 @@ +print("This should never run because dependency installation should fail") diff --git a/crates/tower-runtime/tests/example-apps/05-broken-dependencies/pyproject.toml b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/pyproject.toml new file mode 100644 index 00000000..ce68498c --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/05-broken-dependencies/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "05-broken-dependencies" +version = "0.1.0" +description = "App with a non-existent dependency to test failure handling" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "this-package-definitely-does-not-exist-xyz-123456789>=1.0.0", +] diff --git a/crates/tower-runtime/tests/local_test.rs b/crates/tower-runtime/tests/local_test.rs index d8ee4a6d..02069308 100644 --- a/crates/tower-runtime/tests/local_test.rs +++ b/crates/tower-runtime/tests/local_test.rs @@ -328,3 +328,50 @@ async fn test_running_app_with_secret() { let status = app.status().await.expect("Failed to get app status"); assert!(status == Status::Exited, "App should be running"); } + +#[tokio::test] +async fn test_abort_on_dependency_installation_failure() { + debug!("Running 05-broken-dependencies"); + // This test verifies that when dependency installation fails (uv sync returns non-zero), + // the app correctly reports a Crashed status rather than continuing execution. + let broken_deps_dir = get_example_app_dir("05-broken-dependencies"); + let package = build_package_from_dir(&broken_deps_dir).await; + let (sender, mut receiver) = unbounded_channel(); + + let opts = StartOptions { + ctx: tower_telemetry::Context::new(), + package, + output_sender: sender, + cwd: None, + environment: "local".to_string(), + secrets: HashMap::new(), + parameters: HashMap::new(), + env_vars: HashMap::new(), + cache_dir: Some(config::default_cache_dir()), + }; + + // Start the app using the LocalApp runtime + let app = LocalApp::start(opts).await.expect("Failed to start app"); + + // Drain all output - we need to consume the channel for the app to complete + while let Some(output) = receiver.recv().await { + debug!("Received output: {:?}", output.line); + } + + // The status should be Crashed since dependency installation failed + let status = app.status().await.expect("Failed to get app status"); + match status { + Status::Crashed { code } => { + assert!(code != 0, "Exit code should be non-zero, got {}", code); + } + Status::Exited => { + panic!("App should have crashed due to dependency installation failure, not exited successfully"); + } + Status::Running => { + panic!("App should not still be running"); + } + Status::None => { + panic!("App should have a status"); + } + } +}