Skip to content

Commit fa455f7

Browse files
committed
fix(launcher): honor OPENCODEX_BUN_PATH
1 parent 0666b41 commit fa455f7

3 files changed

Lines changed: 273 additions & 1 deletion

File tree

bin/ocx.mjs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,22 @@ function bunBinDir() {
292292
// the ~450-byte stub in place, which is NOT executable (ENOEXEC). A size gate
293293
// cleanly distinguishes the stub from a real binary on every platform.
294294
const REAL_BUN_MIN_BYTES = 1_000_000;
295+
const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";
296+
297+
function isRealBunBinary(path) {
298+
try {
299+
return existsSync(path) && statSync(path).size >= REAL_BUN_MIN_BYTES;
300+
} catch {
301+
return false;
302+
}
303+
}
295304

296305
function findBunBinary(bunDir) {
297306
// The npm `bun` package ships the binary as bin/bun.exe on every platform;
298307
// probe bin/bun too for forward compatibility.
299308
for (const name of ["bun.exe", "bun"]) {
300309
const p = join(bunDir, "bin", name);
301-
if (existsSync(p) && statSync(p).size >= REAL_BUN_MIN_BYTES) return p;
310+
if (isRealBunBinary(p)) return p;
302311
}
303312
return null;
304313
}
@@ -317,6 +326,11 @@ function fail(msg) {
317326
}
318327

319328
function resolveBun() {
329+
// Keep direct npm-launcher starts aligned with durable service/shim installs:
330+
// a valid explicit runtime must win even when the bundled dependency exists.
331+
const override = process.env[BUN_OVERRIDE_ENV]?.trim();
332+
if (override && isRealBunBinary(override)) return override;
333+
320334
let bunDir;
321335
try {
322336
bunDir = bunBinDir();

tests/ocx-launcher-runtime.test.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
3+
import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4+
import { createServer } from "node:net";
5+
import { tmpdir } from "node:os";
6+
import { join, resolve } from "node:path";
7+
import { bundledBunPath } from "../src/lib/bun-runtime";
8+
import { killProxy } from "../src/lib/process-control";
9+
10+
const BIN_OCX = join(import.meta.dir, "..", "bin", "ocx.mjs");
11+
const nodeAvailable = spawnSync("node", ["--version"], {
12+
stdio: "ignore",
13+
windowsHide: true,
14+
}).status === 0;
15+
const runnable = process.platform === "win32" && nodeAvailable;
16+
17+
type Health = {
18+
status: string;
19+
service: string;
20+
pid: number;
21+
port: number;
22+
};
23+
24+
type WindowsProcessIdentity = {
25+
pid: number;
26+
parentPid: number;
27+
executablePath: string;
28+
creationDate: string;
29+
};
30+
31+
function freePort(): Promise<number> {
32+
return new Promise((resolvePort, reject) => {
33+
const server = createServer();
34+
server.on("error", reject);
35+
server.listen(0, "127.0.0.1", () => {
36+
const address = server.address();
37+
const port = typeof address === "object" && address ? address.port : 0;
38+
server.close(() => (port ? resolvePort(port) : reject(new Error("no port"))));
39+
});
40+
});
41+
}
42+
43+
async function healthAt(port: number): Promise<Health | null> {
44+
try {
45+
const response = await fetch(`http://127.0.0.1:${port}/healthz`, {
46+
signal: AbortSignal.timeout(800),
47+
});
48+
if (!response.ok) return null;
49+
const body = await response.json() as Health;
50+
return body?.status === "ok"
51+
&& body.service === "opencodex"
52+
&& Number.isSafeInteger(body.pid)
53+
&& body.pid > 0
54+
&& body.port === port
55+
? body
56+
: null;
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
async function waitForHealth(
63+
port: number,
64+
deadlineMs: number,
65+
launcher: ChildProcess,
66+
): Promise<Health | null> {
67+
const deadline = Date.now() + deadlineMs;
68+
while (Date.now() < deadline) {
69+
if (launcher.exitCode !== null) return null;
70+
const health = await healthAt(port);
71+
if (health) return health;
72+
await Bun.sleep(200);
73+
}
74+
return null;
75+
}
76+
77+
function windowsProcessIdentity(pid: number): WindowsProcessIdentity | null {
78+
const result = spawnSync(
79+
"powershell.exe",
80+
[
81+
"-NoProfile",
82+
"-NonInteractive",
83+
"-Command",
84+
`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $p = Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\"; if ($null -ne $p) { $p | Select-Object ProcessId, ParentProcessId, ExecutablePath, CreationDate | ConvertTo-Json -Compress }`,
85+
],
86+
{ encoding: "utf8", timeout: 10_000, windowsHide: true },
87+
);
88+
if (result.status !== 0) throw new Error(`could not inspect process identity: ${result.stderr.trim()}`);
89+
if (!result.stdout.trim()) return null;
90+
const value = JSON.parse(result.stdout) as {
91+
ProcessId?: number;
92+
ParentProcessId?: number;
93+
ExecutablePath?: string;
94+
CreationDate?: string;
95+
};
96+
if (
97+
!Number.isSafeInteger(value.ProcessId)
98+
|| !Number.isSafeInteger(value.ParentProcessId)
99+
|| typeof value.ExecutablePath !== "string"
100+
|| typeof value.CreationDate !== "string"
101+
) {
102+
throw new Error("process identity response was incomplete");
103+
}
104+
return {
105+
pid: value.ProcessId!,
106+
parentPid: value.ParentProcessId!,
107+
executablePath: value.ExecutablePath,
108+
creationDate: value.CreationDate,
109+
};
110+
}
111+
112+
function canonicalWindowsPath(path: string): string {
113+
try {
114+
return realpathSync.native(path).toLowerCase();
115+
} catch {
116+
return resolve(path).toLowerCase();
117+
}
118+
}
119+
120+
function sameWindowsPath(actual: string, expected: string): boolean {
121+
return canonicalWindowsPath(actual) === canonicalWindowsPath(expected);
122+
}
123+
124+
function sameProcess(actual: WindowsProcessIdentity | null, expected: WindowsProcessIdentity): boolean {
125+
return actual !== null
126+
&& actual.pid === expected.pid
127+
&& actual.creationDate === expected.creationDate
128+
&& sameWindowsPath(actual.executablePath, expected.executablePath);
129+
}
130+
131+
function removeTree(path: string): void {
132+
// Windows can retain the copied executable's image handle briefly after
133+
// taskkill returns. Retry only transient fixture-cleanup errors, with a cap.
134+
let lastError: unknown;
135+
for (let attempt = 0; attempt < 50; attempt += 1) {
136+
try {
137+
rmSync(path, { recursive: true, force: true });
138+
return;
139+
} catch (error) {
140+
const code = error && typeof error === "object" && "code" in error
141+
? String(error.code)
142+
: "";
143+
if (!new Set(["EPERM", "EBUSY", "ENOTEMPTY"]).has(code)) throw error;
144+
lastError = error;
145+
Bun.sleepSync(200);
146+
}
147+
}
148+
throw lastError;
149+
}
150+
151+
async function effectiveRuntime(override: string): Promise<string> {
152+
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-"));
153+
const opencodexHome = join(root, "opencodex");
154+
const codexHome = join(root, "codex");
155+
const grokHome = join(root, "grok");
156+
mkdirSync(opencodexHome, { recursive: true });
157+
mkdirSync(codexHome, { recursive: true });
158+
mkdirSync(grokHome, { recursive: true });
159+
160+
const port = await freePort();
161+
let launcher: ChildProcess | null = null;
162+
let ownedLauncher: WindowsProcessIdentity | null = null;
163+
let ownedProxy: WindowsProcessIdentity | null = null;
164+
try {
165+
launcher = spawn("node", [BIN_OCX, "start", "--port", String(port)], {
166+
stdio: "ignore",
167+
windowsHide: true,
168+
env: {
169+
...process.env,
170+
HOME: root,
171+
USERPROFILE: root,
172+
OPENCODEX_HOME: opencodexHome,
173+
CODEX_HOME: codexHome,
174+
GROK_HOME: grokHome,
175+
OPENCODEX_BUN_PATH: override,
176+
},
177+
});
178+
if (!launcher.pid) throw new Error("Node launcher has no process id");
179+
ownedLauncher = windowsProcessIdentity(launcher.pid);
180+
if (!ownedLauncher) throw new Error("could not capture Node launcher process identity");
181+
182+
const health = await waitForHealth(port, 25_000, launcher);
183+
if (!health) throw new Error("proxy did not become healthy");
184+
const identity = windowsProcessIdentity(health.pid);
185+
if (!identity || identity.parentPid !== launcher.pid) {
186+
throw new Error("health PID is not the spawned Node launcher's direct Bun child");
187+
}
188+
ownedProxy = identity;
189+
return identity.executablePath;
190+
} finally {
191+
// Both identities were captured from processes this test spawned. Re-query
192+
// before each kill so a reused PID can never become a cleanup target.
193+
const cleanupErrors: string[] = [];
194+
for (const [label, identity] of [
195+
["launcher", ownedLauncher],
196+
["proxy", ownedProxy],
197+
] as const) {
198+
if (!identity || !sameProcess(windowsProcessIdentity(identity.pid), identity)) continue;
199+
try {
200+
killProxy(identity.pid);
201+
} catch (error) {
202+
cleanupErrors.push(`${label} cleanup failed: ${String(error)}`);
203+
}
204+
if (sameProcess(windowsProcessIdentity(identity.pid), identity)) {
205+
cleanupErrors.push(`owned ${label} PID ${identity.pid} remained after bounded cleanup`);
206+
}
207+
}
208+
try {
209+
removeTree(root);
210+
} catch (error) {
211+
cleanupErrors.push(`fixture cleanup failed: ${String(error)}`);
212+
}
213+
if (cleanupErrors.length > 0) throw new Error(cleanupErrors.join("; "));
214+
}
215+
}
216+
217+
describe.skipIf(!runnable)("ocx npm launcher effective Bun runtime", () => {
218+
test("uses a valid OPENCODEX_BUN_PATH for the actual proxy process", async () => {
219+
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-copy-"));
220+
try {
221+
const override = join(root, "override-bun.exe");
222+
copyFileSync(process.execPath, override);
223+
expect(sameWindowsPath(await effectiveRuntime(override), override)).toBe(true);
224+
} finally {
225+
removeTree(root);
226+
}
227+
}, 120_000);
228+
229+
test("falls back to bundled Bun for a sub-1MB override stub", async () => {
230+
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-stub-"));
231+
try {
232+
const stub = join(root, "stub-bun.exe");
233+
writeFileSync(stub, "not a Bun executable", "utf8");
234+
const bundled = bundledBunPath();
235+
expect(bundled).not.toBeNull();
236+
expect(sameWindowsPath(await effectiveRuntime(stub), bundled!)).toBe(true);
237+
} finally {
238+
removeTree(root);
239+
}
240+
}, 120_000);
241+
});

tests/ocx-launcher-source.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,21 @@ describe("ocx.mjs npm launcher (source invariants)", () => {
2222
expect(source).toContain('if (explicit === "preview" || explicit === "latest") return explicit;');
2323
expect(source).not.toMatch(/if \(tagIndex !== -1 && process\.argv\[tagIndex \+ 1\]\) return process\.argv/);
2424
});
25+
26+
test("valid Bun overrides are selected before the bundled runtime", () => {
27+
expect(source).toContain('const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";');
28+
expect(source).toContain("if (override && isRealBunBinary(override)) return override;");
29+
30+
const resolveStart = source.indexOf("function resolveBun() {");
31+
const overrideCheck = source.indexOf("process.env[BUN_OVERRIDE_ENV]?.trim()", resolveStart);
32+
const bundledLookup = source.indexOf("bunDir = bunBinDir()", resolveStart);
33+
expect(resolveStart).toBeGreaterThanOrEqual(0);
34+
expect(overrideCheck).toBeGreaterThan(resolveStart);
35+
expect(bundledLookup).toBeGreaterThan(overrideCheck);
36+
});
37+
38+
test("invalid Bun overrides fall back without throwing", () => {
39+
expect(source).toContain("function isRealBunBinary(path) {");
40+
expect(source).toMatch(/function isRealBunBinary\(path\) \{[\s\S]*?try \{[\s\S]*?statSync\(path\)[\s\S]*?catch \{[\s\S]*?return false;/);
41+
});
2542
});

0 commit comments

Comments
 (0)