olragon

Why doesn't Bun.spawn see the env var I just set?

🌱 seedlingplanted #bun#terminal#lessons

Written for Anyone writing Bun scripts or scheduled job runners that load keys from a .env file at runtime and then start child processes.

I used to believe that once you assign something to process.env, everything that runs after can see it. In Bun, it turns out, that isn't so.

Every number below I measured on the morning of 1 November on Bun 1.4.4, once on my Mac and once on the Linux VM that serves this very garden. The probe script is under 30 lines; it is linked at the end.

Tiếng Việt: Vì sao Bun.spawn chẳng thấy biến môi trường mình vừa gán?.

The alarm that arrived on time, every morning

There's a website I work on that mails people new matches for what they signed up for. The mailing runs inside a scheduled job runner. The runner starts, loads the mail key from a config file into process.env, then starts each job as a child process with Bun.spawn.

Every morning, an alarm landed in the inbox: "every send failed". It made no sense. The key was on the server. The loader read it, and when I printed it, it was there.

The alarm ran inside the parent process, so it saw the key and mailed just fine. The mailing job ran in a child, and the child had no key at all. So the only thing in the world that could send mail was the mail complaining that no mail could be sent. I had to read that part a few times before I believed it.

Bun.spawn takes the environment from startup

The culprit was a line I never suspected. Call Bun.spawn without passing env, and the child gets the environment from when the script started, not process.env at the moment of the call.

I measured seven ways to start a child, and they don't agree. The script sets OLR_SPAWN_PROBE=set-at-runtime on process.env, then asks a child what it sees. The result is identical on macOS and on Linux:

how the child is starteddoes it see the new variable?
Bun.spawn(cmd)no
Bun.spawnSync(cmd)no
Bun.spawn(cmd, { env: process.env })yes
Bun.spawn(cmd, { env: { ...process.env } })yes
` Bun.$… `yes
node:child_process spawnSyncyes
node:child_process execSyncyes

What surprised me most were the Bun.$ and node:child_process rows. Same runtime, same variable, and three ways of calling see it while two don't. If you come from Node you'll assume Bun.spawn behaves like child_process, and that's exactly the trap.

Changing or deleting a variable misses too

I tried two more cases, to be sure it isn't only new variables.

  • Start with OLR_PRE=boot, then set process.env.OLR_PRE = "changed". A Bun.spawnSync child prints boot.
  • Delete HOME with delete process.env.HOME. The child still sees the old HOME, untouched.

So every change to process.env after startup fails to reach the child, unless you pass env. Adding, changing, deleting: all three. This kind of bug is hard to spot because it's silent. Nothing throws; there's just an empty variable on the other side.

The fix is one line; prevention takes a bit more

The fix is short. Wherever you call Bun.spawn or Bun.spawnSync and want the child to see the current environment, pass it in explicitly:

Bun.spawn(cmd, { env: process.env });

The job runner now passes env where it starts each job, and the mail went out again the next morning.

But I didn't want to rely on memory alone. So the runner gained a small check job that runs exactly as a child does, and reports whether it can see the mail key. The old alarm ran in the parent, so it saw a different world from the job it was guarding. A check you can trust has to stand where the thing it checks stands.

I also went through this garden's own scripts to see whether any were affected. Six files call Bun.spawn, but none assigns to process.env at runtime, so nothing misses yet. Not missing yet doesn't mean never missing, so I keep the probe and run it whenever I upgrade Bun.

Try it on your machine

Paste this into a file probe.ts and run bun probe.ts:

process.env.PROBE = "set-at-runtime";
const sh = ["sh", "-c", 'printf %s "${PROBE:-<missing>}"'];
console.log("spawnSync:", Bun.spawnSync(sh).stdout.toString());
console.log("with env: ", Bun.spawnSync(sh, { env: process.env }).stdout.toString());

If the first line prints <missing> and the second prints set-at-runtime, your machine has the same trap. The full version, which measures all seven ways, lives in the garden's repo as scripts/bun/spawn-env-check.ts.

It's a bit like the time pkill -f cat closed every app: a harmless-looking command doing exactly what it was written to do, which just isn't what I thought it did.

What I still don't know

  • I don't know whether Bun designed it this way on purpose or whether it's a bug that will be fixed. I haven't searched Bun's issue tracker.
  • I only measured Bun 1.4.4. Whether older versions or the canary differ, I haven't tried.
  • I don't understand why Bun.$ sees it and Bun.spawn doesn't, when both are Bun's own.

If you know why, or have hit this trap somewhere else, write me a letter below.

Write papa dragon a letter

If this note stirred something, write to me here. A letter reaches only me and never shows up on the page by itself. If one is lovely and you allow it, I may quote it under the note, without your name.

What the garden keeps from a letter

Works from a terminal too: curl -d 'msg=…' olragon.com/garden/bun-spawn-env-en/letter