Skip to content
10 changes: 10 additions & 0 deletions docs/introspection.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@
```bash
CI=1 CODSPEED_DEBUG=true pnpm --filter with-typescript-esm bench-tinybench
```

## Vitest process isolation

Isolated Vitest children restrict benchmark discovery to the file named in
`CODSPEED_ISOLATED_BENCHMARK`. Other benchmark files are not transformed or imported
in that child. The parent still discovers the full suite, and each child still
loads the Vitest configuration and setup files.

Isolated children retain fresh processes and the profiling flags. This avoids
repeated unrelated file imports; it does not remove Vitest's per-process startup.
130 changes: 80 additions & 50 deletions packages/benchmark.js-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
getCallingFile,
InstrumentHooks,
Isolation,
mongoMeasurement,
optimizeFunction,
optimizeFunctionSync,
Expand Down Expand Up @@ -168,63 +169,92 @@ async function runBenchmarks({
baseUri,
benchmarkCompletedListeners,
}: RunBenchmarksOptions): Promise<void> {
console.log(`[CodSpeed] running with @codspeed/benchmark.js v${__VERSION__}`);
const isolation = Isolation.current();
const isChild = isolation.isChild();
const selected = benches
.map((bench, i) => ({
bench,
uri: bench.uri ?? `${baseUri}::unknown_${i}`,
}))
.filter(({ uri }) => isolation.shouldRun(uri));
// A child only measures one benchmark. A run call that owns none of them
// must not touch the instruments, the call that owns it does.
if (isChild && selected.length === 0) {
return;
}

// The parent already printed the banner and the summary, children stay quiet.
if (!isChild) {
console.log(
`[CodSpeed] running with @codspeed/benchmark.js v${__VERSION__}`,
);
}
setupCore();
for (let i = 0; i < benches.length; i++) {
const bench = benches[i];
const uri = bench.uri ?? `${baseUri}::unknown_${i}`;
const isAsync = bench.options.async || bench.options.defer;
let benchPayload;
if (bench.options.defer) {
benchPayload = () => {
return new Promise((resolve, reject) => {
(bench.fn as CallableFunction)({ resolve, reject });
});
};
} else if (bench.options.async) {
benchPayload = async () => {
await (bench.fn as CallableFunction)();
};
} else {
benchPayload = bench.fn as CallableFunction;
for (const { bench, uri } of selected) {
await isolation.run(uri, () => measureBenchmark(bench, uri));
// The parent reports for every benchmark, the child ran only one of them.
if (!isChild) {
benchmarkCompletedListeners.forEach((listener) => listener());
}
}
teardownCore();
if (!isChild) {
console.log(`[CodSpeed] Done running ${benches.length} benches.`);
}
}

if (typeof bench.options.setup === "function") {
await bench.options.setup();
}
async function measureBenchmark(
bench: BenchmarkWithOptions,
uri: string,
): Promise<void> {
const isAsync = bench.options.async || bench.options.defer;
let benchPayload;
if (bench.options.defer) {
benchPayload = () => {
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
(bench.fn as CallableFunction)({ resolve, reject });
return promise;
};
} else if (bench.options.async) {
benchPayload = async () => {
await (bench.fn as CallableFunction)();
};
} else {
benchPayload = bench.fn as CallableFunction;
}

if (isAsync) {
await optimizeFunction(benchPayload);
await mongoMeasurement.start(uri);
global.gc?.();
await wrapWithRootFrame(async () => {
InstrumentHooks.startBenchmark();
await benchPayload();
InstrumentHooks.stopBenchmark();
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
})();
await mongoMeasurement.stop(uri);
} else {
optimizeFunctionSync(benchPayload);
await mongoMeasurement.start(uri);
wrapWithRootFrameSync(() => {
InstrumentHooks.startBenchmark();
benchPayload();
InstrumentHooks.stopBenchmark();
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
})();
await mongoMeasurement.stop(uri);
}
if (typeof bench.options.setup === "function") {
await bench.options.setup();
}

if (typeof bench.options.teardown === "function") {
await bench.options.teardown();
}
if (isAsync) {
await optimizeFunction(benchPayload);
await mongoMeasurement.start(uri);
global.gc?.();
await wrapWithRootFrame(async () => {
InstrumentHooks.startBenchmark();
await benchPayload();
InstrumentHooks.stopBenchmark();
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
})();
await mongoMeasurement.stop(uri);
} else {
optimizeFunctionSync(benchPayload);
await mongoMeasurement.start(uri);
wrapWithRootFrameSync(() => {
InstrumentHooks.startBenchmark();
benchPayload();
InstrumentHooks.stopBenchmark();
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
})();
await mongoMeasurement.stop(uri);
}

console.log(` ✔ Measured ${uri}`);
benchmarkCompletedListeners.forEach((listener) => listener());
if (typeof bench.options.teardown === "function") {
await bench.options.teardown();
}
teardownCore();
console.log(`[CodSpeed] Done running ${benches.length} benches.`);

console.log(` ✔ Measured ${uri}`);
}

/**
Expand Down
37 changes: 37 additions & 0 deletions packages/benchmark.js-plugin/tests/fixtures/isolation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Run by `isolation.integ.test.ts`. Prints the PID that measures each benchmark.
const { InstrumentHooks } = require("@codspeed/core");
const Benchmark = require("benchmark");
const { withCodSpeed } = require("../../dist/index.cjs.js");

// There is no instrument hook runtime outside the CodSpeed runner, so pretend
// the hooks are active. The child processes run this file again, so they get
// the same override.
InstrumentHooks.isInstrumented = () => true;

// The plugin reports every measured benchmark here, with the PID of the
// process that measured it.
const setExecutedBenchmark = InstrumentHooks.setExecutedBenchmark;
InstrumentHooks.setExecutedBenchmark = (pid, uri) => {
setExecutedBenchmark(pid, uri);
console.log(`executed ${pid} ${uri}`);
};

const suite = withCodSpeed(new Benchmark.Suite("isolation"));

suite
.add("first", () => {})
.add("second", () => {})
.add("third", () => {})
.add("async", {
defer: true,
fn: (deferred) => deferred.resolve(),
});

if (process.env.ISOLATION_FIXTURE_FAIL !== undefined) {
suite.add("failing", () => {
throw new Error("benchmark failed");
});
}

// An unhandled rejection ends the process with exit code 1.
suite.run();
4 changes: 4 additions & 0 deletions packages/benchmark.js-plugin/tests/index.integ.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jest.mock("@codspeed/core", () => {
const actual = jest.requireActual("@codspeed/core");
mockCore.getGitDir = actual.getGitDir;
mockCore.getCallingFile = actual.getCallingFile;
mockCore.Isolation = actual.Isolation;
mockCore.wrapWithRootFrame = actual.wrapWithRootFrame;
mockCore.wrapWithRootFrameSync = actual.wrapWithRootFrameSync;
return mockCore;
Expand All @@ -19,6 +20,9 @@ jest.mock("@codspeed/core", () => {
beforeEach(() => {
mockReset(mockCore);
jest.clearAllMocks();
// These tests drive the plugin in this process against a mocked core, so
// there is no benchmark process to spawn.
process.env.CODSPEED_PROCESS_ISOLATION = "false";
});

const benchOptions: Benchmark.Options = {
Expand Down
82 changes: 82 additions & 0 deletions packages/benchmark.js-plugin/tests/isolation.integ.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { getV8Flags } from "@codspeed/core";
import { spawnSync } from "child_process";
import { join } from "path";

const FIXTURE = join(__dirname, "fixtures", "isolation.cjs");
const PROCESS_ISOLATION_ENV = "CODSPEED_PROCESS_ISOLATION";
/** `first`, `second`, `third` and the deferred `async` benchmark. */
const BENCHMARK_COUNT = 4;
/** Fails a run whose benchmark process got stuck instead of blocking the suite. */
const RUN_TIMEOUT_MS = 60_000;
const EXECUTED_LINE = /^executed (\d+) (.+)$/;

interface FixtureOptions {
/** Value of CODSPEED_PROCESS_ISOLATION, unset when undefined. */
isolation?: string;
failing?: boolean;
}

/** Runs the fixture and returns the PID that measured each benchmark. */
function runFixture({ isolation, failing }: FixtureOptions = {}) {
const env: NodeJS.ProcessEnv = {
...process.env,
CODSPEED_ENV: "true",
CODSPEED_RUNNER_MODE: "simulation",
};
delete env[PROCESS_ISOLATION_ENV];
if (isolation !== undefined) {
env[PROCESS_ISOLATION_ENV] = isolation;
}
if (failing) {
env.ISOLATION_FIXTURE_FAIL = "1";
}

// The runner starts Node with these flags, and children must keep them.
const result = spawnSync(process.execPath, [...getV8Flags(), FIXTURE], {
env,
encoding: "utf8",
timeout: RUN_TIMEOUT_MS,
});

const pidsByUri = new Map<string, number>();
for (const line of result.stdout.split("\n")) {
const match = EXECUTED_LINE.exec(line);
if (match) {
pidsByUri.set(match[2], Number(match[1]));
}
}
return {
status: result.status,
stderr: result.stderr,
pidsByUri,
parentPid: result.pid,
};
}

describe("process isolation", () => {
it("measures every benchmark in its own process", () => {
const run = runFixture();

expect(run.status).toBe(0);
expect(run.pidsByUri.size).toBe(BENCHMARK_COUNT);
const pids = new Set(run.pidsByUri.values());
expect(pids.size).toBe(BENCHMARK_COUNT);
// The parent only spawns children, it never measures.
expect(pids).not.toContain(run.parentPid);
});

it("measures all benchmarks in one process when disabled", () => {
const run = runFixture({ isolation: "false" });

expect(run.status).toBe(0);
expect(run.pidsByUri.size).toBe(BENCHMARK_COUNT);
expect(new Set(run.pidsByUri.values())).toEqual(new Set([run.parentPid]));
});

it("fails the run when a benchmark throws", () => {
const run = runFixture({ isolation: "true", failing: true });

expect(run.status).not.toBe(0);
expect(run.stderr).toContain("benchmark failed");
});
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type {
SetupInstrumentsResponse,
} from "./generated/openapi";
export { getV8Flags, tryIntrospect } from "./introspection";
export * from "./isolation";
export {
getUnsupportedNodeVersionWarning,
SUPPORTED_NODE_MAJORS,
Expand Down
Loading
Loading