From c802c29ee4dbe29aa81d99fd2902cdf1f815124f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 19 Oct 2026 17:01:41 +0900 Subject: [PATCH 1/3] fix(runtime): scope server children's transpiler cache and keep scoping idempotent Server spawns of the bundled runtime (startup-health probe, dashboard startup install, Windows tray) bypassed the launcher and still evicted the test runner's cache. Scope them through durableBunChildEnv when the child is a different Bun version; re-scope an already scoped path in place; accept prerelease/build versions; type the helper. --- src/lib/bun-runtime.ts | 19 ++++++++++++- src/lib/bun-transpiler-cache-scope.d.mts | 8 ++++++ src/lib/bun-transpiler-cache-scope.mjs | 18 ++++++++++-- src/server/startup-action-control.ts | 4 +-- src/server/startup-health-cache.ts | 4 +-- src/server/windows-tray-control.ts | 4 +-- structure/ops/cross-platform-ci.md | 3 ++ tests/cli/bun-transpiler-cache-scope.test.ts | 30 +++++++++++++++++++- 8 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 src/lib/bun-transpiler-cache-scope.d.mts diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index ffe5e0b53f2..54e215dfe54 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -11,9 +11,10 @@ * back to `process.execPath` (which is itself Bun when run via `bun src/cli/index.ts`). */ import { createRequire } from "node:module"; -import { realpathSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { isRealBunBinary } from "./bun-binary-validator.mjs"; +import { scopeTranspilerCacheToRuntime } from "./bun-transpiler-cache-scope.mjs"; import { isStandaloneBinary } from "./standalone"; export { isRealBunBinary }; @@ -186,3 +187,19 @@ export function durableBunRuntime(): DurableBunRuntime { export function durableBunPath(): string { return durableBunRuntime().path; } + +/** + * Environment for a child started on {@link durableBunPath}. When that is the bundled runtime + * and this process is a different Bun (the CI test runner), an inherited transpiler cache is + * scoped to the child's version: the two releases' .pile formats evict each other. + */ +export function durableBunChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const runtime = durableBunRuntime(); + if (runtime.source !== "bundled" || samePath(runtime.path, process.execPath)) return env; + let version: unknown; + try { + version = JSON.parse(readFileSync(join(dirname(dirname(runtime.path)), "package.json"), "utf8")).version; + } catch { return env; } + if (typeof version !== "string" || version === Bun.version) return env; + return scopeTranspilerCacheToRuntime(env, version, join); +} diff --git a/src/lib/bun-transpiler-cache-scope.d.mts b/src/lib/bun-transpiler-cache-scope.d.mts new file mode 100644 index 00000000000..fc4f8167ad8 --- /dev/null +++ b/src/lib/bun-transpiler-cache-scope.d.mts @@ -0,0 +1,8 @@ +export declare const TRANSPILER_CACHE_ENV: "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; + +export declare function scopeTranspilerCacheToRuntime>( + env: T, + runtimeVersion: string | null | undefined, + join: (...parts: string[]) => string, + platform?: NodeJS.Platform, +): T; diff --git a/src/lib/bun-transpiler-cache-scope.mjs b/src/lib/bun-transpiler-cache-scope.mjs index e497186c3d3..37f441a5b37 100644 --- a/src/lib/bun-transpiler-cache-scope.mjs +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -13,10 +13,20 @@ * A disabled cache ("0" or empty), an unset one, or an unknown version is left as given. * Windows environment names are case-insensitive (Bun's lookup too) while this object is not, * so on win32 every spelling of the name is matched and replaced by one scoped entry. + * A cache that is already scoped (a launcher started from a launched process) is re-scoped in + * place, never nested, so the same runtime keeps reaching the same warm directory. */ export const TRANSPILER_CACHE_ENV = "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; -const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; +// Semver core, optional prerelease and build metadata (findPathBun accepts `+build`). The +// alphabet has no separator, drive colon or NUL, so the segment cannot leave the cache. +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const SCOPED_TAIL = /^(.*?)[\\/]+(bun-[^\\/]+)[\\/]*$/; + +/** @param {unknown} version */ +function validVersion(version) { + return typeof version === "string" && version.length <= 64 && VERSION.test(version); +} /** * @param {Record} env @@ -33,10 +43,12 @@ export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join, platfor const name = names.includes(TRANSPILER_CACHE_ENV) ? TRANSPILER_CACHE_ENV : names[0]; const cache = name === undefined ? undefined : env[name]; if (typeof cache !== "string" || cache === "" || cache === "0") return env; - if (typeof runtimeVersion !== "string" || !VERSION.test(runtimeVersion)) return env; + if (!validVersion(runtimeVersion)) return env; + const tail = SCOPED_TAIL.exec(cache); + const base = tail && validVersion(tail[2].slice(4)) ? (tail[1] || cache.slice(0, cache.length - tail[2].length)) : cache; const scoped = { ...env }; for (const variant of names) delete scoped[variant]; - scoped[TRANSPILER_CACHE_ENV] = join(cache, `bun-${runtimeVersion}`); + scoped[TRANSPILER_CACHE_ENV] = join(base, `bun-${runtimeVersion}`); return scoped; } diff --git a/src/server/startup-action-control.ts b/src/server/startup-action-control.ts index 3aa900cf825..50aa8927add 100644 --- a/src/server/startup-action-control.ts +++ b/src/server/startup-action-control.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import { join } from "node:path"; -import { durableBunPath } from "../lib/bun-runtime"; +import { durableBunChildEnv, durableBunPath } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { isDesktopSupervised } from "../lib/system-restart-contract"; import { withLoginShellPath } from "./login-shell-path"; @@ -196,7 +196,7 @@ async function runCliInstall( isStandaloneExecutable: installLaunch.isStandaloneExecutable, }); const run = installLaunch.execFile ?? execFile; - const env = await installChildEnv(action); + const env = durableBunChildEnv(await installChildEnv(action)); return new Promise((resolve, reject) => { const timeout = process.platform === "win32" && action === "install-service" ? 0 diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts index ec16797a1ea..60860338a71 100644 --- a/src/server/startup-health-cache.ts +++ b/src/server/startup-health-cache.ts @@ -4,7 +4,7 @@ import { codexAutoStartEnabled } from "../config"; import { deriveStartupHealth, startupHealthProbeBudgetMs, type StartupHealth } from "../codex/autostart-health"; import { getCodexRoutingKind } from "../codex/inject"; import { diagnoseCodexShim } from "../codex/shim"; -import { durableBunPath } from "../lib/bun-runtime"; +import { durableBunChildEnv, durableBunPath } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { desktopStartupOwnership } from "../service/desktop-startup"; import type { OcxConfig } from "../types"; @@ -129,7 +129,7 @@ function runProbe(config: Pick): Promise { execFile(bun, selfLaunchArgv(["__startup-health"], { sourceEntrypoint: cli }), { encoding: "utf8", - env: process.env, + env: durableBunChildEnv(process.env), timeout: PROBE_TIMEOUT_MS, windowsHide: true, maxBuffer: 256 * 1024, diff --git a/src/server/windows-tray-control.ts b/src/server/windows-tray-control.ts index bc59ef894c3..6a2f4404247 100644 --- a/src/server/windows-tray-control.ts +++ b/src/server/windows-tray-control.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { join } from "node:path"; -import { durableBunPath } from "../lib/bun-runtime"; +import { durableBunChildEnv, durableBunPath } from "../lib/bun-runtime"; import type { WindowsTrayStatus } from "../tray/windows"; export type WindowsTrayAction = "install" | "start" | "stop" | "status" | "uninstall"; @@ -15,7 +15,7 @@ export function runWindowsTrayAction(action: WindowsTrayAction): Promise { execFile(bun, [cli, "tray", action, "--json"], { encoding: "utf8", - env: process.env, + env: durableBunChildEnv(process.env), timeout: 30_000, windowsHide: true, maxBuffer: 256 * 1024, diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 4b479732eed..42bd5da264b 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -94,6 +94,9 @@ so the two versions sharing one cache evict each other on every start, and each loaded its CLI graph cold (a 15 s Windows `spawnSync` timeout in run 38697651428). The launcher therefore passes its child `/bun-` (`src/lib/bun-transpiler-cache-scope.mjs`); `tests/cli/bun-transpiler-cache-scope.test.ts` covers it. +Server children started on the bundled runtime from a different Bun (startup-health probe, +dashboard startup install, Windows tray) get the same scope through `durableBunChildEnv` in +`src/lib/bun-runtime.ts`; an already scoped path is re-scoped in place, never nested. Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` (`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). Across the jobs, the workflow runs: diff --git a/tests/cli/bun-transpiler-cache-scope.test.ts b/tests/cli/bun-transpiler-cache-scope.test.ts index 9936d853821..be0a0bcd1cc 100644 --- a/tests/cli/bun-transpiler-cache-scope.test.ts +++ b/tests/cli/bun-transpiler-cache-scope.test.ts @@ -2,8 +2,9 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { scopeTranspilerCacheToRuntime, TRANSPILER_CACHE_ENV } from "../../src/lib/bun-transpiler-cache-scope.mjs"; +import { durableBunChildEnv, durableBunRuntime } from "../../src/lib/bun-runtime"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { COLD_SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -41,6 +42,33 @@ describe("launcher transpiler cache scope", () => { expect(scopeTranspilerCacheToRuntime(posix, "1.4.2", join, "linux")).toBe(posix); }); + test("an already scoped cache is re-scoped in place, never nested", () => { + const scoped = (cache: string, version: string) => + scopeTranspilerCacheToRuntime({ [TRANSPILER_CACHE_ENV]: cache }, version, join)[TRANSPILER_CACHE_ENV]; + expect(scoped(join("/cache", "bun-1.4.2"), "1.4.2")).toBe(join("/cache", "bun-1.4.2")); + expect(scoped(join("/cache", "bun-1.4.2"), "1.5.0")).toBe(join("/cache", "bun-1.5.0")); + expect(scoped(join("/cache", "bun-1.4.2") + "/", "1.4.2")).toBe(join("/cache", "bun-1.4.2")); + expect(scoped("C:\\cache\\bun-1.4.2", "1.4.2")).toBe(join("C:\\cache", "bun-1.4.2")); + // A directory that merely starts with bun- but is not a version stays a parent. + expect(scoped(join("/cache", "bun-cache"), "1.4.2")).toBe(join("/cache", "bun-cache", "bun-1.4.2")); + }); + + test("prerelease and build metadata versions are accepted, oversized ones are not", () => { + const env = { [TRANSPILER_CACHE_ENV]: "/cache" }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.0+build.1", join)[TRANSPILER_CACHE_ENV]).toBe(join("/cache", "bun-1.4.0+build.1")); + expect(scopeTranspilerCacheToRuntime(env, "1.4.3-canary-1", join)[TRANSPILER_CACHE_ENV]).toBe(join("/cache", "bun-1.4.3-canary-1")); + expect(scopeTranspilerCacheToRuntime(env, "1.4.3-" + "a".repeat(64), join)).toBe(env); + }); + + test("server children on the bundled runtime get a scoped cache only across a version change", () => { + const env = { ...process.env, [TRANSPILER_CACHE_ENV]: join(tmpdir(), "ocx-durable-scope") }; + const runtime = durableBunRuntime(); + const child = durableBunChildEnv(env); + if (runtime.source !== "bundled") { expect(child).toBe(env); return; } + const bundled = JSON.parse(readFileSync(join(dirname(dirname(runtime.path)), "package.json"), "utf8")).version as string; + expect(child[TRANSPILER_CACHE_ENV]).toBe(bundled === Bun.version ? env[TRANSPILER_CACHE_ENV] : join(env[TRANSPILER_CACHE_ENV]!, `bun-${bundled}`)); + }); + // CI shares one cache between the test-runner Bun and the bundled Bun the launcher starts. // Their .pile formats differ and each deletes the other's entries, so before this scope every // launcher child re-transpiled its CLI graph cold (15 s spawnSync timeouts on Windows). From aedd824871b0d7ffcdcffef664bc4815683e58b0 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 19 Oct 2026 17:03:07 +0900 Subject: [PATCH 2/3] fix(runtime): keep the root when re-scoping a root-level transpiler cache --- src/lib/bun-transpiler-cache-scope.mjs | 6 ++++-- tests/cli/bun-transpiler-cache-scope.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/bun-transpiler-cache-scope.mjs b/src/lib/bun-transpiler-cache-scope.mjs index 37f441a5b37..c2a0e48cda9 100644 --- a/src/lib/bun-transpiler-cache-scope.mjs +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -21,7 +21,7 @@ export const TRANSPILER_CACHE_ENV = "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; // Semver core, optional prerelease and build metadata (findPathBun accepts `+build`). The // alphabet has no separator, drive colon or NUL, so the segment cannot leave the cache. const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -const SCOPED_TAIL = /^(.*?)[\\/]+(bun-[^\\/]+)[\\/]*$/; +const SCOPED_TAIL = /^(.*?)([\\/]+)(bun-[^\\/]+)[\\/]*$/; /** @param {unknown} version */ function validVersion(version) { @@ -45,7 +45,9 @@ export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join, platfor if (typeof cache !== "string" || cache === "" || cache === "0") return env; if (!validVersion(runtimeVersion)) return env; const tail = SCOPED_TAIL.exec(cache); - const base = tail && validVersion(tail[2].slice(4)) ? (tail[1] || cache.slice(0, cache.length - tail[2].length)) : cache; + // At a filesystem or drive root ("/", "C:\\") keep one separator: a bare "C:" is drive-relative. + const base = !tail || !validVersion(tail[3].slice(4)) ? cache + : tail[1] === "" || /^[A-Za-z]:$/.test(tail[1]) ? tail[1] + tail[2][0] : tail[1]; const scoped = { ...env }; for (const variant of names) delete scoped[variant]; scoped[TRANSPILER_CACHE_ENV] = join(base, `bun-${runtimeVersion}`); diff --git a/tests/cli/bun-transpiler-cache-scope.test.ts b/tests/cli/bun-transpiler-cache-scope.test.ts index be0a0bcd1cc..baf60f434b1 100644 --- a/tests/cli/bun-transpiler-cache-scope.test.ts +++ b/tests/cli/bun-transpiler-cache-scope.test.ts @@ -51,6 +51,10 @@ describe("launcher transpiler cache scope", () => { expect(scoped("C:\\cache\\bun-1.4.2", "1.4.2")).toBe(join("C:\\cache", "bun-1.4.2")); // A directory that merely starts with bun- but is not a version stays a parent. expect(scoped(join("/cache", "bun-cache"), "1.4.2")).toBe(join("/cache", "bun-cache", "bun-1.4.2")); + // Root-level scopes keep their root, with or without a trailing separator. + expect(scoped("/bun-1.4.2/", "1.5.0")).toBe(join("/", "bun-1.5.0")); + expect(scoped("/bun-1.4.2", "1.5.0")).toBe(join("/", "bun-1.5.0")); + expect(scoped("C:\\bun-1.4.2\\", "1.5.0")).toBe(join("C:\\", "bun-1.5.0")); }); test("prerelease and build metadata versions are accepted, oversized ones are not", () => { From 537263099e38ad12c06dcf3b2ae3e518cc544d6f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 19 Oct 2026 17:07:25 +0900 Subject: [PATCH 3/3] fix(runtime): re-scope relative and drive-relative transpiler caches in place --- src/lib/bun-transpiler-cache-scope.mjs | 24 +++++++++++++++----- tests/cli/bun-transpiler-cache-scope.test.ts | 4 ++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/lib/bun-transpiler-cache-scope.mjs b/src/lib/bun-transpiler-cache-scope.mjs index c2a0e48cda9..b108175eb0d 100644 --- a/src/lib/bun-transpiler-cache-scope.mjs +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -21,7 +21,20 @@ export const TRANSPILER_CACHE_ENV = "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; // Semver core, optional prerelease and build metadata (findPathBun accepts `+build`). The // alphabet has no separator, drive colon or NUL, so the segment cannot leave the cache. const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -const SCOPED_TAIL = /^(.*?)([\\/]+)(bun-[^\\/]+)[\\/]*$/; +const SCOPED_TAIL = /^(.*?)([\\/]*)(bun-[^\\/]+)[\\/]*$/; +const DRIVE = /^[A-Za-z]:$/; + +/** The directory a scoped cache lives in, or null when the path is not a bun- scope. */ +function scopeParent(cache) { + const tail = SCOPED_TAIL.exec(cache); + if (!tail || !validVersion(tail[3].slice(4))) return null; + const [, prefix, separator] = tail; + // Without a separator the segment must start the path ("bun-1.4.2") or follow a drive + // ("C:bun-1.4.2", drive-relative); "xbun-1.4.2" is an ordinary directory name. + if (separator === "") return prefix === "" ? "." : DRIVE.test(prefix) ? prefix : null; + // At a filesystem or drive root ("/", "C:\\") keep one separator: a bare "C:" is drive-relative. + return prefix === "" || DRIVE.test(prefix) ? prefix + separator[0] : prefix; +} /** @param {unknown} version */ function validVersion(version) { @@ -44,13 +57,12 @@ export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join, platfor const cache = name === undefined ? undefined : env[name]; if (typeof cache !== "string" || cache === "" || cache === "0") return env; if (!validVersion(runtimeVersion)) return env; - const tail = SCOPED_TAIL.exec(cache); - // At a filesystem or drive root ("/", "C:\\") keep one separator: a bare "C:" is drive-relative. - const base = !tail || !validVersion(tail[3].slice(4)) ? cache - : tail[1] === "" || /^[A-Za-z]:$/.test(tail[1]) ? tail[1] + tail[2][0] : tail[1]; + const base = scopeParent(cache) ?? cache; const scoped = { ...env }; for (const variant of names) delete scoped[variant]; - scoped[TRANSPILER_CACHE_ENV] = join(base, `bun-${runtimeVersion}`); + const segment = `bun-${runtimeVersion}`; + // join("C:", x) yields "C:\\x"; a drive-relative parent must stay drive-relative. + scoped[TRANSPILER_CACHE_ENV] = DRIVE.test(base) ? base + segment : join(base, segment); return scoped; } diff --git a/tests/cli/bun-transpiler-cache-scope.test.ts b/tests/cli/bun-transpiler-cache-scope.test.ts index baf60f434b1..05e9357fbe1 100644 --- a/tests/cli/bun-transpiler-cache-scope.test.ts +++ b/tests/cli/bun-transpiler-cache-scope.test.ts @@ -55,6 +55,10 @@ describe("launcher transpiler cache scope", () => { expect(scoped("/bun-1.4.2/", "1.5.0")).toBe(join("/", "bun-1.5.0")); expect(scoped("/bun-1.4.2", "1.5.0")).toBe(join("/", "bun-1.5.0")); expect(scoped("C:\\bun-1.4.2\\", "1.5.0")).toBe(join("C:\\", "bun-1.5.0")); + // Relative and drive-relative scopes re-scope too; a name merely ending in bun- does not. + expect(scoped("bun-1.4.2", "1.5.0")).toBe(join(".", "bun-1.5.0")); + expect(scoped(scoped(".", "1.5.0")!, "1.5.0")).toBe(join(".", "bun-1.5.0")); + expect(scoped("xbun-1.4.2", "1.5.0")).toBe(join("xbun-1.4.2", "bun-1.5.0")); }); test("prerelease and build metadata versions are accepted, oversized ones are not", () => {