From 1cb1e60da38d8d5923ae59d2e531333743256bd7 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 19 Oct 2026 16:43:23 +0900 Subject: [PATCH 1/2] fix(launcher): scope an inherited transpiler cache to the Bun version it starts The CI shard cache is shared by the test-runner Bun (1.4.3) and the bundled runtime the launcher starts (1.4.2). Bun keys .pile entries by source hash and deletes entries written in another release's format, so each version evicted the other's on every start and launcher children loaded the CLI graph cold (15 s Windows spawnSync timeout, run 38697651428). Pass the child /bun-; leave disabled/unset caches and overrides untouched. Drop the attest test's cold budget now that the cause is gone. --- bin/ocx.mjs | 19 +++++-- scripts/test-layout/layout.json | 1 + src/lib/bun-transpiler-cache-scope.mjs | 31 ++++++++++++ structure/ops/cross-platform-ci.md | 6 +++ structure/runtime.md | 2 +- tests/cli/bun-transpiler-cache-scope.test.ts | 50 +++++++++++++++++++ .../codex-cli-update-zero-effect.test.ts | 14 +++--- tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 src/lib/bun-transpiler-cache-scope.mjs create mode 100644 tests/cli/bun-transpiler-cache-scope.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6baf5168b5f..0d235f59556 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -37,6 +37,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; +import { scopeTranspilerCacheToRuntime } from "../src/lib/bun-transpiler-cache-scope.mjs"; import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; @@ -1003,6 +1004,15 @@ function findBunBinary(bunDir) { return null; } +function bundledBunVersion(bunDir) { + try { + const version = JSON.parse(readFileSync(join(bunDir, "package.json"), "utf8")).version; + return typeof version === "string" ? version : null; + } catch { + return null; + } +} + function fail(msg) { const reinstall = installMethod === "pnpm" ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" @@ -1039,7 +1049,7 @@ function resolveBun({ allowInstall = true } = {}) { } catch { /* Missing dependency can still fall back to a validated PATH Bun. */ } let bin = bunDir ? findBunBinary(bunDir) : null; - if (bin) return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) }; // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. @@ -1048,7 +1058,7 @@ function resolveBun({ allowInstall = true } = {}) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } - if (bin) return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) }; // Reached only when the bundled runtime is unusable. The two probes start the candidate Bun // cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for @@ -1056,7 +1066,7 @@ function resolveBun({ allowInstall = true } = {}) { const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); if (pathBun) { console.error(`opencodex: using PATH Bun ${pathBun.version}.`); - return { path: pathBun.path, source: "process" }; + return { path: pathBun.path, source: "process", version: pathBun.version }; } fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } @@ -1230,7 +1240,8 @@ const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, // the long-running Bun child, and closing that window kills the proxy (#1236). windowsHide: true, env: { - ...inheritedEnv, + // A cache shared with another Bun version (the CI test runner's) is evicted on every start. + ...scopeTranspilerCacheToRuntime(inheritedEnv, bunRuntime.version, join), [NODE_LAUNCH_CONTEXT_ENV]: launchContext, [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ae8abf42f8b..1c6e4ff4d1b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -288,6 +288,7 @@ "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", + "bun-transpiler-cache-scope.test.ts": "cli", "bun-stream-caps.test.ts": "lib", "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", diff --git a/src/lib/bun-transpiler-cache-scope.mjs b/src/lib/bun-transpiler-cache-scope.mjs new file mode 100644 index 00000000000..5b807e39847 --- /dev/null +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -0,0 +1,31 @@ +/** + * Scope an inherited Bun runtime transpiler cache to the Bun version this launcher starts. + * + * Bun keys `.pile` entries by source-content hash alone and stamps them with a format + * version that changes between releases (1.4.2 writes 28, 1.4.3 writes 34). A reader that + * finds the other format deletes and rewrites the entry. When one explicit cache directory + * is shared by two Bun versions, each start of one evicts what the other just wrote, so + * neither ever runs warm. That is the CI shape: the test runner (`testRunnerBun`) and the + * bundled runtime this launcher starts share the shard's `BUN_RUNTIME_TRANSPILER_CACHE_PATH`, + * and every launcher spawn re-transpiled its whole CLI graph from nothing. + * + * A per-version subdirectory keeps each runtime's entries stable. Bun creates the directory. + * A disabled cache ("0" or empty), an unset one, or an unknown version is left as given. + */ +export const TRANSPILER_CACHE_ENV = "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; + +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; + +/** + * @param {Record} env + * @param {string | null | undefined} runtimeVersion + * @param {(...parts: string[]) => string} join + * @returns {Record} + */ +export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join) { + const cache = env[TRANSPILER_CACHE_ENV]; + if (typeof cache !== "string" || cache === "" || cache === "0") return env; + if (typeof runtimeVersion !== "string" || !VERSION.test(runtimeVersion)) return env; + return { ...env, [TRANSPILER_CACHE_ENV]: join(cache, `bun-${runtimeVersion}`) }; +} + diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 09640f00b86..4b479732eed 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -88,6 +88,12 @@ must be evaluated without raising test deadlines or weakening assertions. An exp including "" or "0" to disable caching, is preserved and remains the operator's protected-path responsibility. `tests/ci-workflows/test-runner.test.ts` covers isolation, legacy-path refusal, cleanup ownership, overrides and actual Bun children with different homes. +The published launcher (`bin/ocx.mjs`) starts the bundled Bun, which is not the test-runner Bun. +Bun keys `.pile` entries by source hash and deletes an entry written in another release's format, +so the two versions sharing one cache evict each other on every start, and each launcher child +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. 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/structure/runtime.md b/structure/runtime.md index b2e7d8a85dd..3585af3cc68 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -102,7 +102,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). An inherited explicit `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is scoped to `bun-` of the bundled or PATH runtime it starts (`src/lib/bun-transpiler-cache-scope.mjs`), because another Bun version sharing the directory evicts its entries. Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/cli/bun-transpiler-cache-scope.test.ts b/tests/cli/bun-transpiler-cache-scope.test.ts new file mode 100644 index 00000000000..417b7707fe3 --- /dev/null +++ b/tests/cli/bun-transpiler-cache-scope.test.ts @@ -0,0 +1,50 @@ +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 { scopeTranspilerCacheToRuntime, TRANSPILER_CACHE_ENV } from "../../src/lib/bun-transpiler-cache-scope.mjs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { COLD_SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); + +describe("launcher transpiler cache scope", () => { + test("an explicit cache is scoped to the started runtime's version", () => { + const env = { [TRANSPILER_CACHE_ENV]: "/cache", OTHER: "kept" }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.2", join)).toEqual({ [TRANSPILER_CACHE_ENV]: join("/cache", "bun-1.4.2"), OTHER: "kept" }); + expect(env[TRANSPILER_CACHE_ENV]).toBe("/cache"); + }); + + test("a disabled, unset or unknown-version cache is left as given", () => { + for (const value of ["0", "", undefined]) { + const env = { [TRANSPILER_CACHE_ENV]: value }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.2", join)).toBe(env); + } + const env = { [TRANSPILER_CACHE_ENV]: "/cache" }; + for (const version of [null, undefined, "", "../1.4.2", "1.4", "latest"]) { + expect(scopeTranspilerCacheToRuntime(env, version, join)).toBe(env); + } + }); + + // 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). + test.skipIf(spawnSync("node", ["--version"], { windowsHide: true }).status !== 0)( + "the published launcher writes its runtime's entries beside the runner's, never over them", () => { + const cache = mkdtempSync(join(tmpdir(), "ocx-transpiler-scope-")); + roots.push(cache); + const bundled = JSON.parse(readFileSync(repoPath("node_modules", "bun", "package.json"), "utf8")).version as string; + const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "--version"], { + cwd: repoRoot(), encoding: "utf8", timeout: COLD_SPAWN_BUDGET_MS - 5_000, windowsHide: true, + env: { ...process.env, [TRANSPILER_CACHE_ENV]: cache, OPENCODEX_BUN_PATH: "" }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(readdirSync(cache)).toEqual([`bun-${bundled}`]); + expect(existsSync(join(cache, `bun-${bundled}`)) && readdirSync(join(cache, `bun-${bundled}`)).length > 0).toBe(true); + }, COLD_SPAWN_BUDGET_MS); +}); + diff --git a/tests/codex-integration/codex-cli-update-zero-effect.test.ts b/tests/codex-integration/codex-cli-update-zero-effect.test.ts index bd239caaefb..d985ea3144d 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; -import { COLD_SPAWN_BUDGET_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; const roots: string[] = []; afterEach(() => { @@ -43,15 +43,15 @@ describe("Codex CLI updater zero-effect boundary", () => { })); const stateFiles = ["codex-shim.json", "codex-runtime.json", "config.json"].map(name => join(home, name)); for (const path of stateFiles) writeFileSync(path, "{broken"); - // This first Node -> Bun inspection also loads the Windows native reader cold. - // Dev run 38697651428 exhausted 15s; the next attest took 4.6s (previously 0.5–0.8s). - // Keep the real launcher and every zero-effect assertion; use the shared cold budget - // only here, leaving 5s for assertions/cleanup before the surrounding test deadline. + // Dev run 38697651428 exhausted 15s here: the launcher's bundled Bun shared the shard's + // transpiler cache with the differently versioned test runner, and each evicted the other's + // entries, so this child re-transpiled the CLI graph cold. The launcher now scopes that + // cache per runtime version (tests/cli/bun-transpiler-cache-scope.test.ts). const result = spawnSync("node", [ repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "attest", "--candidate", candidate, "--npm-prefix", prefix, "--npm-cli", npmCli, "--node", node, "--json", ], { - cwd: repoRoot(), encoding: "utf8", timeout: COLD_SPAWN_BUDGET_MS - 5_000, windowsHide: true, + cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: home, CODEX_CLI_PATH: selectedCandidate, npm_config_registry: "http://127.0.0.1:9", npm_config_offline: "true", @@ -70,7 +70,7 @@ describe("Codex CLI updater zero-effect boundary", () => { expect(result.stdout).not.toContain("ocx-codex-attest-zero-effect-"); for (const path of stateFiles) expect(readFileSync(path, "utf8")).toBe("{broken"); for (const marker of [candidateMarker, npmMarker, selectedMarker]) expect(existsSync(marker)).toBe(false); - }, COLD_SPAWN_BUDGET_MS, + }, SPAWN_BUDGET_MS, ); (process.platform === "win32" && process.arch === "x64" ? test : test.skip)( diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bdb6b3af5ee..b7c535715ab 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -287,6 +287,7 @@ "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "ci-gui-typecheck-gate.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", + "bun-transpiler-cache-scope.test.ts": "cli", "bun-stream-caps.test.ts": "lib", "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-client-fanout.test.ts": "codex-integration", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", "catalog-custom-ordering.test.ts": "codex-integration", From bd03bd454d741e6bc616e7c0a3004707946c8f5c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 19 Oct 2026 16:48:00 +0900 Subject: [PATCH 2/2] fix(launcher): match the cache variable case-insensitively on Windows --- src/lib/bun-transpiler-cache-scope.mjs | 17 ++++++++++++++--- tests/cli/bun-transpiler-cache-scope.test.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lib/bun-transpiler-cache-scope.mjs b/src/lib/bun-transpiler-cache-scope.mjs index 5b807e39847..e497186c3d3 100644 --- a/src/lib/bun-transpiler-cache-scope.mjs +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -11,6 +11,8 @@ * * A per-version subdirectory keeps each runtime's entries stable. Bun creates the directory. * 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. */ export const TRANSPILER_CACHE_ENV = "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; @@ -20,12 +22,21 @@ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; * @param {Record} env * @param {string | null | undefined} runtimeVersion * @param {(...parts: string[]) => string} join + * @param {NodeJS.Platform} [platform] * @returns {Record} */ -export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join) { - const cache = env[TRANSPILER_CACHE_ENV]; +export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join, platform = process.platform) { + const wanted = TRANSPILER_CACHE_ENV.toLowerCase(); + const names = platform === "win32" + ? Object.keys(env).filter(name => name.toLowerCase() === wanted) + : Object.hasOwn(env, TRANSPILER_CACHE_ENV) ? [TRANSPILER_CACHE_ENV] : []; + 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; - return { ...env, [TRANSPILER_CACHE_ENV]: join(cache, `bun-${runtimeVersion}`) }; + const scoped = { ...env }; + for (const variant of names) delete scoped[variant]; + scoped[TRANSPILER_CACHE_ENV] = join(cache, `bun-${runtimeVersion}`); + return scoped; } diff --git a/tests/cli/bun-transpiler-cache-scope.test.ts b/tests/cli/bun-transpiler-cache-scope.test.ts index 417b7707fe3..9936d853821 100644 --- a/tests/cli/bun-transpiler-cache-scope.test.ts +++ b/tests/cli/bun-transpiler-cache-scope.test.ts @@ -29,6 +29,18 @@ describe("launcher transpiler cache scope", () => { } }); + test("Windows matches every spelling of the name and leaves one scoped entry", () => { + const env = { Bun_Runtime_Transpiler_Cache_Path: "C:\\cache", OTHER: "kept" }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.2", join, "win32")) + .toEqual({ [TRANSPILER_CACHE_ENV]: join("C:\\cache", "bun-1.4.2"), OTHER: "kept" }); + const both = { [TRANSPILER_CACHE_ENV]: "C:\\exact", bun_runtime_transpiler_cache_path: "C:\\other" }; + expect(scopeTranspilerCacheToRuntime(both, "1.4.2", join, "win32")) + .toEqual({ [TRANSPILER_CACHE_ENV]: join("C:\\exact", "bun-1.4.2") }); + // POSIX names are case-sensitive: Bun never reads the lower-case spelling there. + const posix = { bun_runtime_transpiler_cache_path: "/cache" }; + expect(scopeTranspilerCacheToRuntime(posix, "1.4.2", join, "linux")).toBe(posix); + }); + // 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).