Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/lib/bun-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
}
8 changes: 8 additions & 0 deletions src/lib/bun-transpiler-cache-scope.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export declare const TRANSPILER_CACHE_ENV: "BUN_RUNTIME_TRANSPILER_CACHE_PATH";

export declare function scopeTranspilerCacheToRuntime<T extends Record<string, string | undefined>>(
env: T,
runtimeVersion: string | null | undefined,
join: (...parts: string[]) => string,
platform?: NodeJS.Platform,
): T;
32 changes: 29 additions & 3 deletions src/lib/bun-transpiler-cache-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version> 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<string, string | undefined>} env
Expand All @@ -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;
}

4 changes: 2 additions & 2 deletions src/server/startup-action-control.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/server/startup-health-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -129,7 +129,7 @@ function runProbe(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHea
return new Promise(resolve => {
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,
Expand Down
4 changes: 2 additions & 2 deletions src/server/windows-tray-control.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,7 +15,7 @@ export function runWindowsTrayAction(action: WindowsTrayAction): Promise<Windows
return new Promise((resolve, reject) => {
execFile(bun, [cli, "tray", action, "--json"], {
encoding: "utf8",
env: process.env,
env: durableBunChildEnv(process.env),
timeout: 30_000,
windowsHide: true,
maxBuffer: 256 * 1024,
Expand Down
3 changes: 3 additions & 0 deletions structure/ops/cross-platform-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cache>/bun-<version>` (`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:
Expand Down
38 changes: 37 additions & 1 deletion tests/cli/bun-transpiler-cache-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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-<v> 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).
Expand Down
Loading