Skip to content

Commit 4601945

Browse files
fix(tinybench-plugin): support tinybench v5 and v6
tinybench v6 moved the benchmark function, its options, and the resolved bench options behind ES private fields (and flattened the options onto the bench instance), which broke the plugin's reliance on reaching those internals through casts: analysis mode crashed with "fn is not a function" and walltime mode crashed reading `bench.opts.setup`. Capture the function and options ourselves when `bench.add` runs, normalize option access across the v4/v5 `bench.opts` layout and the v6 flattened one, bake the walltime root frame into the registered function instead of mutating the (now private) task function, and opt back into sample retention that v6 disabled by default. Add dedicated example fixtures pinning tinybench v5 and v6 so CI exercises every supported major, excluding them from the Node 18 leg since tinybench v5+ requires Node >=20. Closes COD-2929 Co-Authored-By: Claude <noreply@anthropic.com> Generated with AI Agent (Claude Code)
1 parent 2475d5b commit 4601945

14 files changed

Lines changed: 281 additions & 35 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ jobs:
5151
matrix:
5252
node-version: ["18", "20.5.1"]
5353
example: ${{ fromJson(needs.list-examples.outputs.examples) }}
54+
# tinybench v5+ requires Node >=20, so its examples cannot run on Node 18
55+
exclude:
56+
- node-version: "18"
57+
example: "with-tinybench-v5"
58+
- node-version: "18"
59+
example: "with-tinybench-v6"
5460
fail-fast: false
5561
steps:
5662
- uses: "actions/checkout@v4"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "with-tinybench-v5",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"bench-tinybench": "node --loader esbuild-register/loader -r esbuild-register tinybench.ts",
7+
"typecheck": "tsc --noEmit --pretty"
8+
},
9+
"devDependencies": {
10+
"@codspeed/tinybench-plugin": "workspace:*",
11+
"esbuild-register": "^3.4.2",
12+
"tinybench": "^5.1.0",
13+
"typescript": "^5.1.3"
14+
}
15+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { withCodSpeed } from "@codspeed/tinybench-plugin";
2+
import { Bench } from "tinybench";
3+
4+
function recursiveFibonacci(n: number): number {
5+
if (n < 2) {
6+
return n;
7+
}
8+
return recursiveFibonacci(n - 1) + recursiveFibonacci(n - 2);
9+
}
10+
11+
// The plugin resolves tinybench's `Bench` type from the version hoisted at the
12+
// monorepo root, which differs from the major pinned in this example. A real
13+
// consumer has a single installed tinybench, so this mismatch is local to the
14+
// workspace; `@ts-expect-error` keeps the example typechecking and flags us if
15+
// the discrepancy ever resolves on its own.
16+
// @ts-expect-error cross-version Bench type mismatch within the monorepo
17+
const bench = withCodSpeed(new Bench({ time: 100, warmup: true }));
18+
19+
bench
20+
.add("recursive fibo 10", () => {
21+
recursiveFibonacci(10);
22+
})
23+
// Register the per-task hooks so the plugin's handling of them is exercised.
24+
.add(
25+
"recursive fibo 15 with hooks",
26+
() => {
27+
recursiveFibonacci(15);
28+
},
29+
{
30+
beforeAll() {},
31+
beforeEach() {},
32+
afterEach() {},
33+
afterAll() {},
34+
},
35+
);
36+
37+
bench.run().then(() => {
38+
console.table(bench.table());
39+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["es2023"],
4+
"module": "ESNext",
5+
"target": "es2022",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"skipLibCheck": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"moduleResolution": "Node"
11+
}
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "with-tinybench-v6",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"bench-tinybench": "node --loader esbuild-register/loader -r esbuild-register tinybench.ts",
7+
"typecheck": "tsc --noEmit --pretty"
8+
},
9+
"devDependencies": {
10+
"@codspeed/tinybench-plugin": "workspace:*",
11+
"esbuild-register": "^3.4.2",
12+
"tinybench": "^6.0.2",
13+
"typescript": "^5.1.3"
14+
}
15+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { withCodSpeed } from "@codspeed/tinybench-plugin";
2+
import { Bench } from "tinybench";
3+
4+
function recursiveFibonacci(n: number): number {
5+
if (n < 2) {
6+
return n;
7+
}
8+
return recursiveFibonacci(n - 1) + recursiveFibonacci(n - 2);
9+
}
10+
11+
// The plugin resolves tinybench's `Bench` type from the version hoisted at the
12+
// monorepo root, which differs from the major pinned in this example. A real
13+
// consumer has a single installed tinybench, so this mismatch is local to the
14+
// workspace; `@ts-expect-error` keeps the example typechecking and flags us if
15+
// the discrepancy ever resolves on its own.
16+
// @ts-expect-error cross-version Bench type mismatch within the monorepo
17+
const bench = withCodSpeed(new Bench({ time: 100, warmup: true }));
18+
19+
bench
20+
.add("recursive fibo 10", () => {
21+
recursiveFibonacci(10);
22+
})
23+
// Register the per-task hooks so the plugin's handling of them is exercised.
24+
.add(
25+
"recursive fibo 15 with hooks",
26+
() => {
27+
recursiveFibonacci(15);
28+
},
29+
{
30+
beforeAll() {},
31+
beforeEach() {},
32+
afterEach() {},
33+
afterAll() {},
34+
},
35+
);
36+
37+
bench.run().then(() => {
38+
console.table(bench.table());
39+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["es2023"],
4+
"module": "ESNext",
5+
"target": "es2022",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"skipLibCheck": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"moduleResolution": "Node"
11+
}
12+
}

packages/tinybench-plugin/src/analysis.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
wrapWithRootFrame,
77
wrapWithRootFrameSync,
88
} from "@codspeed/core";
9-
import { Bench, Fn, FnOptions, Task } from "tinybench";
9+
import { Bench, Task } from "tinybench";
1010
import { BaseBenchRunner } from "./shared";
1111

1212
export function setupCodspeedAnalysisBench(
@@ -28,7 +28,7 @@ class AnalysisBenchRunner extends BaseBenchRunner {
2828
}
2929

3030
protected async runTaskAsync(task: Task, uri: string): Promise<void> {
31-
const { fnOpts, fn } = task as unknown as { fnOpts?: FnOptions; fn: Fn };
31+
const { fnOpts, fn } = this.getTaskData(task);
3232

3333
await fnOpts?.beforeAll?.call(task, "run");
3434
await optimizeFunction(async () => {
@@ -50,7 +50,7 @@ class AnalysisBenchRunner extends BaseBenchRunner {
5050
}
5151

5252
protected runTaskSync(task: Task, uri: string): void {
53-
const { fnOpts, fn } = task as unknown as { fnOpts?: FnOptions; fn: Fn };
53+
const { fnOpts, fn } = this.getTaskData(task);
5454

5555
fnOpts?.beforeAll?.call(task, "run");
5656
fnOpts?.beforeEach?.call(task, "run");
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Bench, Hook } from "tinybench";
2+
3+
// The benchmark options tinybench resolves: timing knobs plus the suite-level
4+
// setup/teardown hooks. tinybench guarantees `setup`/`teardown` are populated
5+
// (at least with no-op defaults), so they are non-optional here.
6+
export interface ResolvedBenchOptions {
7+
setup: Hook;
8+
teardown: Hook;
9+
warmup?: boolean;
10+
warmupIterations?: number;
11+
warmupTime?: number;
12+
iterations?: number;
13+
time?: number;
14+
retainSamples?: boolean;
15+
}
16+
17+
// Up to tinybench v5 the resolved options lived under `bench.opts`; from v6 they
18+
// are flattened onto the bench instance itself. Returning the holder object
19+
// (rather than a copy) keeps in-place mutation of `setup`/`teardown` working,
20+
// which the walltime runner relies on to bracket its instrumentation window.
21+
export function getBenchOptions(bench: Bench): ResolvedBenchOptions {
22+
const opts = (bench as unknown as { opts?: ResolvedBenchOptions }).opts;
23+
return opts ?? (bench as unknown as ResolvedBenchOptions);
24+
}

packages/tinybench-plugin/src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
SetupInstrumentsRequestBody,
88
SetupInstrumentsResponse,
99
tryIntrospect,
10+
wrapWithRootFrameSync,
1011
} from "@codspeed/core";
1112
import { Bench } from "tinybench";
1213
import { setupCodspeedAnalysisBench } from "./analysis";
14+
import { getOrCreateTaskDataMap } from "./taskData";
1315
import { getOrCreateUriMap } from "./uri";
1416
import { setupCodspeedWalltimeBench } from "./walltime";
1517

@@ -22,9 +24,11 @@ export function withCodSpeed(bench: Bench): Bench {
2224
}
2325

2426
const rootCallingFile = getCallingFile(1);
27+
const instrumentMode = getInstrumentMode();
2528

2629
// Compute and register URI for bench
2730
const uriMap = getOrCreateUriMap(bench);
31+
const taskDataMap = getOrCreateTaskDataMap(bench);
2832
const rawAdd = bench.add;
2933
bench.add = (name, fn, opts?) => {
3034
const callingFile = getCallingFile(1);
@@ -34,10 +38,17 @@ export function withCodSpeed(bench: Bench): Bench {
3438
}
3539
uri += `::${name}`;
3640
uriMap.set(name, uri);
37-
return rawAdd.bind(bench)(name, fn, opts);
41+
taskDataMap.set(name, { fn, fnOpts: opts });
42+
// In walltime mode the task is driven by tinybench's own measured loop, so
43+
// the root frame must be baked into the function tinybench stores rather
44+
// than injected later (the function is an inaccessible private field on the
45+
// task from tinybench v6 onwards). The sync wrapper is transparent to the
46+
// function's return value, so it preserves tinybench's sync/async handling.
47+
const registeredFn =
48+
instrumentMode === "walltime" ? wrapWithRootFrameSync(fn) : fn;
49+
return rawAdd.bind(bench)(name, registeredFn, opts);
3850
};
3951

40-
const instrumentMode = getInstrumentMode();
4152
if (instrumentMode === "analysis") {
4253
setupCodspeedAnalysisBench(bench, rootCallingFile);
4354
} else if (instrumentMode === "walltime") {

0 commit comments

Comments
 (0)