fix(bun): pass --preserve-symlinks on unbundled execution (#9147)

* fix(bun): pass --preserve-symlinks on unbundled execution

Bun 1.2/1.3 moved its global package cache to a content-addressed
layout and the installer now creates a single directory symlink from
node_modules/<pkg> to the cache entry. Without --preserve-symlinks,
Bun resolves modules from each file's realpath, so any require/import
inside an installed package walks up from cache_nomount/bun/... and
never finds the sibling deps living under <job_dir>/node_modules/.

This manifested as e.g.
  ENOENT while resolving package 'zod/v3' from
  '/tmp/windmill/cache_nomount/bun/@langchain/core@1.1.44@@@1/dist/...'
on //nobundling scripts that pull @langchain/core, even though zod is
correctly installed alongside it in node_modules.

The bundled execution path already had --preserve-symlinks since #4132
(needed because we symlink the cached bundle file into the job dir).
The unbundled path didn't, because at the time Bun installed via per-
file hardlinks and the realpath of node_modules entries was the job
dir itself. The Bun installer's layout change made the flag necessary
on the unbundled path as well.

Add the flag to all three unbundled `bun run` invocations:
- nsjail unbundled path
- non-nsjail unbundled path
- dedicated worker (always unbundled)

This also fixes a latent bug on the first run of any bun script that
imports a package whose internals reference siblings (the build_cache
path runs unbundled this round while it builds the bundle for next
time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(bun): regression test for nobundling + transitive require resolution

Adds an integration test that mirrors the original failure: a //nobundling
script importing @langchain/core, which (in its CJS internals) does
require('zod/v3'). Before --preserve-symlinks was added to the unbundled
bun run invocations, this failed with:

  ENOENT while resolving package 'zod/v3' from
  '.../cache_nomount/bun/@langchain/core@<ver>@@@1/dist/runnables/base.js'

The test covers the non-nsjail unbundled path. Reproducibility of the
pre-fix failure depends on Bun's installer choosing the directory-symlink
layout for the node_modules entry (the default on Bun 1.2/1.3+ with the
new content-addressed global cache that produced the user's error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-13 13:04:31 +00:00
committed by GitHub
parent d243e0cde8
commit 4d0f2c26a1
2 changed files with 60 additions and 0 deletions
+57
View File
@@ -577,6 +577,63 @@ export function main() {
Ok(())
}
/// Regression test: a `//nobundling` script that pulls a package whose CJS
/// internals do bare-specifier `require()` of a sibling dependency.
///
/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the
/// directory symlink in `node_modules/@langchain/core` to its global cache
/// entry, walk parent dirs from the cache realpath, and fail to find
/// `node_modules/zod` — producing:
/// ENOENT while resolving package 'zod/v3' from
/// '.../cache_nomount/bun/@langchain/core@<ver>@@@1/dist/runnables/base.js'
///
/// The fix passes `--preserve-symlinks` so Bun resolves from the
/// symlink path under `<job_dir>/node_modules/`, where `zod` is a sibling.
#[sqlx::test(fixtures("base"))]
async fn test_bun_nobundling_transitive_require(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"//nobundling
import { ChatPromptTemplate } from "@langchain/core/prompts";
export async function main() {
const tpl = ChatPromptTemplate.fromMessages([
["system", "you are a {role}"],
["human", "{input}"],
]);
const out = await tpl.formatMessages({ role: "tester", input: "ping" });
return out.length;
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(2));
Ok(())
}
// ============================================================================
// Native Mode Tests (requires deno_core feature)
// ============================================================================
@@ -2172,6 +2172,7 @@ try {{
"--",
&BUN_PATH,
"run",
"--preserve-symlinks",
"-i",
"--prefer-offline",
"-r",
@@ -2238,6 +2239,7 @@ try {{
} else {
vec![
"run",
"--preserve-symlinks",
"-i",
"--prefer-offline",
"-r",
@@ -3895,6 +3897,7 @@ pub async fn start_worker(
common_bun_proc_envs,
vec![
"run",
"--preserve-symlinks",
"-i",
"--prefer-offline",
"-r",