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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EH42obCk6WJnc7N4Fa25JH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-12 00:02:38 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9fc50a23fb
commit 4afb9aa677
2 changed files with 147 additions and 2 deletions
+70
View File
@@ -990,6 +990,76 @@ export function main() { return [ns === isNumber, label, local()]; }"#
Ok(())
}
async fn bun_dependency_lock(db: &Pool<Postgres>, 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<Postgres>) -> 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<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
+77 -2
View File
@@ -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<i32>,
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
common_bun_proc_envs: &HashMap<String, String>,
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<String, String> = 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,