From 4afb9aa677ac11d22e22e54bb6fd7881378b7005 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 12 Sep 2026 00:02:38 +0200 Subject: [PATCH] fix: bundle deployed bun scripts whose only pin is on a dynamic import (#11096) * fix: bundle deployed bun scripts whose only pin is on a dynamic import Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EH42obCk6WJnc7N4Fa25JH * fix: retry the no-db prebundle too, and guard bundles bun builds as written Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EH42obCk6WJnc7N4Fa25JH * fix: name the bundle retry after the import specifiers it unpins Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EH42obCk6WJnc7N4Fa25JH --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/bun_jobs.rs | 70 ++++++++++++++++++ backend/windmill-worker/src/bun_executor.rs | 79 ++++++++++++++++++++- 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 95b4a62141..590963b291 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -990,6 +990,76 @@ export function main() { return [ns === isNumber, label, local()]; }"# Ok(()) } +async fn bun_dependency_lock(db: &Pool, port: u16, path: &str, content: &str) -> String { + let deps = RunJob::from(JobPayload::RawScriptDependencies { + script_path: path.into(), + content: content.into(), + language: ScriptLang::Bun, + }) + .run_until_complete(db, false, port) + .await + .json_result() + .unwrap(); + let Some(lock) = deps["lock"].as_str() else { + panic!("the dependency job returned no lock: {deps}"); + }; + lock.to_string() +} + +/// Bundling a locked script resolves a pinned dynamic `import()` as written. Where that fails, both +/// the dependency job and a run that finds no cached bundle must still build it, from the version +/// the lock pins; where bun tolerates the failure, the bundle must stay as written. +#[sqlx::test(fixtures("base"))] +async fn test_bun_bundles_pinned_dynamic_import(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The dependency job saves the script's bundle here; the server binary creates it at startup. + std::fs::create_dir_all(&*windmill_worker::BUN_BUNDLE_CACHE_DIR)?; + + const PATH: &str = "f/pinned_dynamic_import/main"; + // Every `script` call draws its own nonce, so each job below misses every bundle cached before + // it, this test's included, and has to build one: a cached bundle skips the build under test. + // 4.17.20 is not npm's `latest`, so a bundle that lost the pin cannot match by accident. + let script = |body: &str| { + format!( + "export async function main() {{\n {body}\n}}\n// {}", + Uuid::new_v4() + ) + }; + let import = r#"const m = await import("lodash@4.17.20"); return (m.default ?? m).VERSION;"#; + + let lock = bun_dependency_lock(&db, port, PATH, &script(import)).await; + let result = RunJob::from(JobPayload::Code(RawCode { + content: script(import), + path: Some(PATH.into()), + language: ScriptLang::Bun, + lock: Some(lock), + ..RawCode::default() + })) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + assert_eq!(result, serde_json::json!("4.17.20")); + + let tolerated = script(&format!("try {{ {import} }} catch {{ return null; }}")); + let lock = bun_dependency_lock(&db, port, PATH, &tolerated).await; + let (bundle, _) = windmill_worker::compute_bundle_local_and_remote_path( + &tolerated, + &lock, + PATH, + Some(&db), + "test-workspace", + &None, + None, + ) + .await; + assert!(std::fs::read_to_string(bundle)?.contains("lodash@4.17.20")); + Ok(()) +} + #[sqlx::test(fixtures("base", "bun_edge_cases"))] async fn test_bun_shared_imports_both_styles(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 16f480815e..a034b0d184 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1146,6 +1146,81 @@ pub async fn generate_bun_bundle( Ok(()) } +/// [`generate_bun_bundle`], built once more with the version pins dropped from the import +/// specifiers of `main.ts` if it fails. The lockfile pins those versions, but bun fails on a +/// pinned specifier except where it tolerates a failed import (in a `try`, under a `.catch`, in +/// dead code). Such a script builds as written and must keep that bundle, so only failures retry. +async fn generate_bun_bundle_unpinning_imports( + job_dir: &str, + w_id: &str, + job_id: &Uuid, + worker_name: &str, + db: Option<&Connection>, + timeout: Option, + mem_peak: &mut i32, + canceled_by: &mut Option, + common_bun_proc_envs: &HashMap, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, +) -> Result<()> { + let built = generate_bun_bundle( + job_dir, + w_id, + job_id, + worker_name, + db, + timeout, + mem_peak, + canceled_by, + common_bun_proc_envs, + occupancy_metrics, + ) + .await; + // Without a job, a failed build comes back as an `ExecutionErr`; with one, that variant is a + // cancellation or timeout, which must not be retried. + let build_failed = match &built { + Err(error::Error::ExitStatus(..)) => true, + Err(_) => db.is_none(), + Ok(()) => false, + }; + if !build_failed { + return built; + } + let Some(unpinned) = read_file_content(&format!("{job_dir}/main.ts")) + .await + .ok() + .and_then(|main| { + remove_pinned_import_specifiers(&main) + .ok() + .filter(|u| *u != main) + }) + else { + return built; + }; + write_file(job_dir, "main.ts", &unpinned)?; + if let Some(db) = db { + append_logs( + job_id, + w_id, + "\nbundling again with the imports' versions taken from the lockfile\n", + db, + ) + .await; + } + generate_bun_bundle( + job_dir, + w_id, + job_id, + worker_name, + db, + timeout, + mem_peak, + canceled_by, + common_bun_proc_envs, + occupancy_metrics, + ) + .await +} + struct PulledCodebase { is_esm: bool, } @@ -1305,7 +1380,7 @@ pub async fn prebundle_bun_script( let common_bun_proc_envs: HashMap = get_common_bun_proc_envs(None).await; - generate_bun_bundle( + generate_bun_bundle_unpinning_imports( job_dir, w_id, job_id, @@ -2202,7 +2277,7 @@ try {{ if !codebase.is_some() && !has_bundle_cache { if build_cache { - generate_bun_bundle( + generate_bun_bundle_unpinning_imports( job_dir, &job.workspace_id, &job.id,