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..b108175eb0d 100644 --- a/src/lib/bun-transpiler-cache-scope.mjs +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -13,10 +13,33 @@ * 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-[^\\/]+)[\\/]*$/; +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) { + return typeof version === "string" && version.length <= 64 && VERSION.test(version); +} /** * @param {Record} env @@ -33,10 +56,13 @@ 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 base = scopeParent(cache) ?? cache; const scoped = { ...env }; for (const variant of names) delete scoped[variant]; - scoped[TRANSPILER_CACHE_ENV] = join(cache, `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/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..05e9357fbe1 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,41 @@ 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")); + // 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")); + // 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", () => { + 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).