Skip to content

Commit 4560b6d

Browse files
rayhanadevclaude
andauthored
perf(core): propagate V8 compile cache to oxlint batch subprocesses (#893)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8bbcca8 commit 4560b6d

6 files changed

Lines changed: 126 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-doctor/core": patch
3+
---
4+
5+
Propagate the V8 compile cache (`NODE_COMPILE_CACHE`) to oxlint batch subprocesses so later batches reuse warm bytecode. Measured ~2% of the lint phase on large multi-batch scans; no benefit for single-batch (`--diff`/`--staged`) scans. The dominant per-child cost is plugin eval/registration, which the compile cache does not address — this is a small internal speedup, not a headline. Opt out with `NODE_DISABLE_COMPILE_CACHE=1`.

packages/core/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ export const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
268268
// binding is markedly slower than on a developer laptop.
269269
export const OXLINT_SPAWN_TIMEOUT_MS = 60_000;
270270

271+
// Directory name appended to os.tmpdir() to form the shared base for the V8
272+
// compile cache. Matches the base Node's own module.enableCompileCache() uses,
273+
// so the bin (parent) and the spawned oxlint batches (children) share one tree.
274+
export const NODE_COMPILE_CACHE_DIR_NAME = "node-compile-cache";
275+
271276
export const DEAD_CODE_WORKER_TIMEOUT_MS = 120_000;
272277

273278
// deslop's semantic pass builds a full TypeScript program and walks

packages/core/src/runners/oxlint/spawn-oxlint.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,9 @@ import {
55
OXLINT_SPAWN_TIMEOUT_MS as DEFAULT_OXLINT_SPAWN_TIMEOUT_MS,
66
} from "../../constants.js";
77
import { OxlintBatchExceeded, OxlintSpawnFailed, ReactDoctorError } from "../../errors.js";
8+
import { buildOxlintChildEnv } from "../../utils/build-oxlint-child-env.js";
89

9-
// HACK: Sanitize child env so a developer's NODE_OPTIONS=--inspect (or
10-
// --max-old-space-size=128, etc.) doesn't leak into oxlint and either spawn a
11-
// debugger port or starve it of memory. We also drop npm_config_* lifecycle
12-
// vars to keep oxlint from picking up package-manager state. PATH, HOME,
13-
// NODE_ENV, NODE_PATH, etc. pass through unchanged.
14-
const SANITIZED_ENV: NodeJS.ProcessEnv = (() => {
15-
const sanitized: NodeJS.ProcessEnv = {};
16-
for (const [name, value] of Object.entries(process.env)) {
17-
if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
18-
if (name.startsWith("npm_config_")) continue;
19-
sanitized[name] = value;
20-
}
21-
return sanitized;
22-
})();
10+
const SANITIZED_ENV: NodeJS.ProcessEnv = buildOxlintChildEnv(process.env);
2311

2412
/**
2513
* Spawn one oxlint subprocess with hard ceilings on wall time and
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os from "node:os";
2+
import * as path from "node:path";
3+
import { NODE_COMPILE_CACHE_DIR_NAME } from "../constants.js";
4+
5+
// Sanitize the child env so a developer's NODE_OPTIONS=--inspect (or
6+
// --max-old-space-size=128, etc.) doesn't leak into oxlint and either spawn a
7+
// debugger port or starve it of memory; drop npm_config_* lifecycle vars so
8+
// oxlint can't pick up package-manager state. PATH, HOME, NODE_ENV, NODE_PATH
9+
// pass through unchanged.
10+
//
11+
// oxlint batches are fresh `node` children that re-parse oxlint + plugin JS
12+
// per spawn. The bin enables the V8 compile cache for the parent only, so
13+
// propagate NODE_COMPILE_CACHE to the children that do the repeated work.
14+
// Set the shared BASE dir (not the parent's version-specific getCompileCacheDir),
15+
// because the child may run a different node (the nvm fallback in
16+
// resolveNodeForOxlint): Node nests each node version under its own subdir of
17+
// the base, so there is no cross-version cache poisoning, and a same-version
18+
// child transparently reads what the parent warmed.
19+
export const buildOxlintChildEnv = (sourceEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv => {
20+
const childEnv: NodeJS.ProcessEnv = {};
21+
for (const [name, value] of Object.entries(sourceEnv)) {
22+
if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
23+
if (name.startsWith("npm_config_")) continue;
24+
childEnv[name] = value;
25+
}
26+
27+
const isCompileCacheDisabled = Boolean(sourceEnv.NODE_DISABLE_COMPILE_CACHE);
28+
const isCompileCacheAlreadySet = childEnv.NODE_COMPILE_CACHE !== undefined;
29+
if (!isCompileCacheDisabled && !isCompileCacheAlreadySet) {
30+
childEnv.NODE_COMPILE_CACHE = path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
31+
}
32+
33+
return childEnv;
34+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import os from "node:os";
2+
import * as path from "node:path";
3+
import { describe, expect, it } from "vite-plus/test";
4+
import { NODE_COMPILE_CACHE_DIR_NAME } from "../src/constants.js";
5+
import { buildOxlintChildEnv } from "../src/utils/build-oxlint-child-env.js";
6+
7+
const SHARED_COMPILE_CACHE_BASE = path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
8+
9+
describe("buildOxlintChildEnv", () => {
10+
it("sets NODE_COMPILE_CACHE to the shared tmp base dir by default", () => {
11+
const childEnv = buildOxlintChildEnv({ PATH: "/usr/bin" });
12+
expect(childEnv.NODE_COMPILE_CACHE).toBe(SHARED_COMPILE_CACHE_BASE);
13+
});
14+
15+
it("respects NODE_DISABLE_COMPILE_CACHE and leaves NODE_COMPILE_CACHE unset", () => {
16+
const childEnv = buildOxlintChildEnv({ PATH: "/usr/bin", NODE_DISABLE_COMPILE_CACHE: "1" });
17+
expect(childEnv.NODE_COMPILE_CACHE).toBeUndefined();
18+
});
19+
20+
it("does not clobber an inherited NODE_COMPILE_CACHE value", () => {
21+
const childEnv = buildOxlintChildEnv({ PATH: "/usr/bin", NODE_COMPILE_CACHE: "/custom/dir" });
22+
expect(childEnv.NODE_COMPILE_CACHE).toBe("/custom/dir");
23+
});
24+
25+
it("preserves an inherited empty-string NODE_COMPILE_CACHE (the guard is !== undefined)", () => {
26+
const childEnv = buildOxlintChildEnv({ PATH: "/usr/bin", NODE_COMPILE_CACHE: "" });
27+
expect(childEnv.NODE_COMPILE_CACHE).toBe("");
28+
});
29+
30+
it("treats the disable and inherited guards independently", () => {
31+
const childEnv = buildOxlintChildEnv({
32+
PATH: "/usr/bin",
33+
NODE_DISABLE_COMPILE_CACHE: "1",
34+
NODE_COMPILE_CACHE: "/custom/dir",
35+
});
36+
expect(childEnv.NODE_COMPILE_CACHE).toBe("/custom/dir");
37+
});
38+
39+
it("still strips NODE_OPTIONS, NODE_DEBUG, and npm_config_* while passing PATH through", () => {
40+
const childEnv = buildOxlintChildEnv({
41+
PATH: "/usr/bin",
42+
NODE_OPTIONS: "--inspect",
43+
NODE_DEBUG: "module",
44+
npm_config_foo: "bar",
45+
});
46+
expect(childEnv.NODE_OPTIONS).toBeUndefined();
47+
expect(childEnv.NODE_DEBUG).toBeUndefined();
48+
expect(childEnv.npm_config_foo).toBeUndefined();
49+
expect(childEnv.PATH).toBe("/usr/bin");
50+
});
51+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os from "node:os";
2+
import * as path from "node:path";
3+
import { describe, expect, it } from "vite-plus/test";
4+
import { NODE_COMPILE_CACHE_DIR_NAME } from "../src/constants.js";
5+
import { spawnOxlint } from "../src/runners/oxlint/spawn-oxlint.js";
6+
7+
// spawn-oxlint.ts captures SANITIZED_ENV from the live process.env at module
8+
// load, so this default-case assertion only holds when the test process itself
9+
// has neither opted out (NODE_DISABLE_COMPILE_CACHE) nor pre-set
10+
// NODE_COMPILE_CACHE — the same two guards buildOxlintChildEnv honors. Skip
11+
// rather than fail spuriously for a developer running with the documented
12+
// opt-out exported; that path is covered by the pure-helper unit test.
13+
const isCompileCacheEnvOverridden =
14+
Boolean(process.env.NODE_DISABLE_COMPILE_CACHE) || process.env.NODE_COMPILE_CACHE !== undefined;
15+
16+
describe("spawnOxlint propagates the V8 compile cache to children", () => {
17+
it.skipIf(isCompileCacheEnvOverridden)(
18+
"child sees NODE_COMPILE_CACHE set to the shared tmp base dir",
19+
async () => {
20+
const stdout = await spawnOxlint(
21+
["-e", "process.stdout.write(process.env.NODE_COMPILE_CACHE ?? 'unset')"],
22+
process.cwd(),
23+
process.execPath,
24+
5_000,
25+
);
26+
expect(stdout).toBe(path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME));
27+
},
28+
);
29+
});

0 commit comments

Comments
 (0)