Skip to content

Commit f511dee

Browse files
--wip-- [skip ci]
1 parent 17467ff commit f511dee

3 files changed

Lines changed: 298 additions & 70 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,25 @@ Based on the codebase analysis, to add stats access features:
9494
- Build: `pnpm turbo run build --filter=<package-name>`
9595
- Typecheck: `pnpm turbo run typecheck --filter=<package-name>`
9696
- Lint: `pnpm turbo run lint --filter=<package-name>`
97-
- Run a task across all packages by omitting `--filter` (e.g. `pnpm turbo run build`).
97+
- Run a task across all packages by omitting `--filter` (e.g. `pnpm turbo run build`).
98+
99+
## Testing a plugin during development
100+
101+
A plugin behaves differently depending on whether CodSpeed is driving the run,
102+
so exercise all three of these when developing or reviewing a plugin change
103+
(build the plugin first — the benches import from `dist`):
104+
105+
1. **Fallback (not under CodSpeed).** No env vars. The plugin must stay out of
106+
the way and let the framework run its benchmarks normally (no instrumentation,
107+
no hijacked output). e.g. `pnpm turbo run bench --filter=<package-name>`.
108+
2. **Instrumentation / simulation.** `CODSPEED_ENV=true CODSPEED_RUNNER_MODE=simulation`
109+
(or `instrumentation`). The plugin hijacks the run to do a single instrumented
110+
pass per benchmark and prints `Measured/Checked <uri>` instead of the normal
111+
harness output.
112+
3. **Walltime.** `CODSPEED_ENV=true CODSPEED_RUNNER_MODE=walltime`. The plugin
113+
instruments the framework's real benchmark loop and collects walltime results.
114+
115+
Running these locally outside the CodSpeed runner is expected to log
116+
`instrument-hooks: failed to write environment.json` and skip actual measurement
117+
writes — the point is to verify the plugin's control flow and output per mode,
118+
not to produce real measurements.

packages/vitest-plugin/src/instrument.ts

Lines changed: 160 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
MARKER_TYPE_BENCHMARK_START,
66
msToNs,
77
msToS,
8+
optimizeFunction,
89
wrapWithRootFrame,
910
writeWalltimeResults,
1011
type Benchmark,
@@ -14,6 +15,21 @@ import type * as tinybench from "tinybench";
1415

1516
export type Tinybench = typeof tinybench;
1617

18+
/** tinybench's per-task lifecycle hooks (a subset of `FnOptions`). */
19+
export interface TinybenchFnOptions {
20+
beforeAll?: (mode?: "run" | "warmup") => unknown;
21+
beforeEach?: (mode?: "run" | "warmup") => unknown;
22+
afterEach?: (mode?: "run" | "warmup") => unknown;
23+
afterAll?: (mode?: "run" | "warmup") => unknown;
24+
}
25+
26+
/** The captured registration for a task: its fn and options. */
27+
export interface CapturedTask {
28+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29+
fn: (...args: any[]) => any;
30+
fnOpts?: TinybenchFnOptions;
31+
}
32+
1733
/** A tinybench task, exposing the `fn` the runner wraps with the root frame. */
1834
export interface TinybenchTask {
1935
name: string;
@@ -34,17 +50,6 @@ export interface TinybenchBench {
3450
teardown: TinybenchHook;
3551
}
3652

37-
/** The minimal task shape `patchTaskRunWithRootFrame` mutates. */
38-
interface RunnableTask {
39-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40-
fn: (...args: any[]) => any;
41-
}
42-
43-
/** The tinybench Task prototype whose `run` we wrap. */
44-
interface TinybenchTaskClass {
45-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46-
prototype: { run: (this: any) => Promise<unknown> };
47-
}
4853

4954
/**
5055
* The tinybench statistics shape (latency/throughput) shared across the v2 and
@@ -77,7 +82,7 @@ interface InstrumentWindow {
7782
runStart: bigint | null;
7883
}
7984

80-
let isTaskPatched = false;
85+
let isBenchAddPatched = false;
8186

8287
/**
8388
* The window bracketing the currently running task's measured loop, driven by
@@ -86,14 +91,85 @@ let isTaskPatched = false;
8691
*/
8792
const instrumentWindow: InstrumentWindow = { runStart: null };
8893

94+
// tinybench keeps a task's fn and options as `#private` fields (v6+), so we
95+
// capture them ourselves when `Bench.add` runs, keyed by bench then task name.
96+
// The analysis seam needs the raw fn to run it under its own tight window
97+
// instead of tinybench's timing loop.
98+
const capturedTasks = new WeakMap<object, Map<string, CapturedTask>>();
99+
100+
/** The minimal tinybench Bench prototype we patch to capture registrations. */
101+
interface TinybenchBenchClass {
102+
prototype: {
103+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
104+
add: (...args: any[]) => unknown;
105+
};
106+
}
107+
108+
/**
109+
* Patch `Bench.prototype.add` to record each task's fn and options, keyed by
110+
* bench then task name. Idempotent, and applied to the prototype so it captures
111+
* registrations on every Bench the host constructs.
112+
*
113+
* `BenchClass` must be the exact class the host instantiates. In tinybench v6 a
114+
* task's fn is a true `#private` field — it cannot be read or replaced on the
115+
* task afterwards — so capturing (and, for walltime, root-frame-wrapping) has to
116+
* happen here, as the fn is registered.
117+
*
118+
* `registerFn` transforms the fn actually handed to tinybench: identity for
119+
* analysis (which runs the captured fn itself), or a root-frame wrap for
120+
* walltime (where tinybench drives the fn and the frame must already be baked
121+
* in).
122+
*/
123+
export function captureBenchAddOnce(
124+
BenchClass: TinybenchBenchClass,
125+
registerFn: (fn: CapturedTask["fn"]) => CapturedTask["fn"],
126+
): void {
127+
if (isBenchAddPatched) {
128+
return;
129+
}
130+
isBenchAddPatched = true;
131+
132+
const originalAdd = BenchClass.prototype.add;
133+
BenchClass.prototype.add = function (
134+
this: object,
135+
name: string,
136+
fn: CapturedTask["fn"],
137+
fnOpts?: TinybenchFnOptions,
138+
) {
139+
let byName = capturedTasks.get(this);
140+
if (!byName) {
141+
byName = new Map<string, CapturedTask>();
142+
capturedTasks.set(this, byName);
143+
}
144+
byName.set(name, { fn, fnOpts });
145+
return originalAdd.call(this, name, registerFn(fn), fnOpts);
146+
};
147+
}
148+
149+
/** Retrieve the fn/options captured for a task on a given bench, if any. */
150+
export function getCapturedTask(
151+
bench: object,
152+
taskName: string,
153+
): CapturedTask | undefined {
154+
return capturedTasks.get(bench)?.get(taskName);
155+
}
156+
157+
/** The tinybench Task prototype whose `run` the legacy seam wraps. */
158+
interface TinybenchTaskClass {
159+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
160+
prototype: { run: (this: any) => Promise<unknown> };
161+
}
162+
163+
let isTaskPatched = false;
164+
89165
/**
90-
* Wrap every task's fn with the root frame so collected stacks are attributed to
91-
* a benchmark. Idempotent: patching the shared `Task.prototype.run` in place hits
92-
* every Bench instance, so repeat calls are no-ops.
166+
* Wrap every task's fn with the root frame by patching `Task.prototype.run` in
167+
* place. Used only by the legacy (Vitest 3/4) walltime seam, which runs on
168+
* tinybench v2 where a task's `fn` is a plain, reassignable property.
93169
*
94-
* `TaskClass` must be the exact prototype the host constructed its tasks against
95-
* (taken from a live task, not imported) so the patch applies even when multiple
96-
* copies of tinybench are installed.
170+
* The Vitest 5 seam cannot use this: tinybench v6 made `fn` a true `#private`
171+
* field, so reassigning `task.fn` is a silent no-op there — the frame must be
172+
* baked in at registration time instead (see rootFrameRegisterFn).
97173
*/
98174
export function patchTaskRunOnce(TaskClass: TinybenchTaskClass): void {
99175
if (isTaskPatched) {
@@ -102,7 +178,7 @@ export function patchTaskRunOnce(TaskClass: TinybenchTaskClass): void {
102178
isTaskPatched = true;
103179

104180
const originalRun = TaskClass.prototype.run;
105-
TaskClass.prototype.run = async function (this: RunnableTask) {
181+
TaskClass.prototype.run = async function (this: CapturedTask) {
106182
const originalFn = this.fn;
107183
this.fn = wrapWithRootFrame(() => originalFn.call(this));
108184

@@ -114,6 +190,53 @@ export function patchTaskRunOnce(TaskClass: TinybenchTaskClass): void {
114190
};
115191
}
116192

193+
/**
194+
* The root-frame wrap to hand tinybench at registration time (walltime, v5).
195+
* Post-hoc assignment to a task's `fn` is a no-op on tinybench v6 (private
196+
* field), so the frame must be baked into the registered fn instead.
197+
*/
198+
export function rootFrameRegisterFn(
199+
fn: CapturedTask["fn"],
200+
): CapturedTask["fn"] {
201+
return wrapWithRootFrame(() => fn());
202+
}
203+
204+
/** Identity registration: analysis runs the captured fn itself, unwrapped. */
205+
export function identityRegisterFn(fn: CapturedTask["fn"]): CapturedTask["fn"] {
206+
return fn;
207+
}
208+
209+
/**
210+
* Run one benchmark under instrumentation, matching the analysis window the
211+
* Vitest 3/4 runner uses exactly: warm the JIT with `optimizeFunction` outside
212+
* the window, run the user hooks around a single measured `fn()`, and bracket
213+
* only that call with `startBenchmark`/`stopBenchmark` under the root frame. The
214+
* measurement comes from the instrument, so no wall-clock markers are emitted
215+
* and tinybench's timing loop is not involved.
216+
*/
217+
export async function runAnalysisTask(
218+
{ fn, fnOpts }: CapturedTask,
219+
uri: string,
220+
): Promise<void> {
221+
await fnOpts?.beforeAll?.("run");
222+
await optimizeFunction(async () => {
223+
await fnOpts?.beforeEach?.("run");
224+
await fn();
225+
await fnOpts?.afterEach?.("run");
226+
});
227+
228+
await fnOpts?.beforeEach?.("run");
229+
global.gc?.();
230+
await wrapWithRootFrame(async () => {
231+
InstrumentHooks.startBenchmark();
232+
await fn();
233+
InstrumentHooks.stopBenchmark();
234+
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
235+
})();
236+
await fnOpts?.afterEach?.("run");
237+
await fnOpts?.afterAll?.("run");
238+
}
239+
117240
/**
118241
* Drive the instrumentation window from each bench's run-mode setup/teardown
119242
* hooks so it brackets only tinybench's measured loop, excluding the warmup
@@ -148,24 +271,29 @@ export function installInstrumentHooks(
148271
}
149272

150273
function closeInstrumentWindow(uri: string): void {
151-
const runEnd = InstrumentHooks.currentTimestamp();
274+
emitBenchmarkWindow(uri, instrumentWindow.runStart!);
275+
instrumentWindow.runStart = null;
276+
}
277+
278+
/**
279+
* Close the currently open instrumentation window: emit the benchmark markers
280+
* bracketing [start, now], stop the benchmark, and attribute the sample to `uri`.
281+
*
282+
* Benchmark markers must land inside the sample window opened by
283+
* startBenchmark(), so they are emitted before stopBenchmark() closes it. The
284+
* runner consumes the FIFO stream in order, so a marker sent after stopBenchmark
285+
* would fall outside the sample and break the expected
286+
* SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
287+
*/
288+
function emitBenchmarkWindow(uri: string, start: bigint): void {
289+
const end = InstrumentHooks.currentTimestamp();
152290
const pid = process.pid;
153291

154-
// Benchmark markers must land inside the sample window opened by
155-
// startBenchmark(), so they have to be emitted before stopBenchmark()
156-
// closes it. The runner consumes the FIFO stream in order, so a marker
157-
// sent after StopBenchmark falls outside the sample and breaks the
158-
// expected SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
159-
InstrumentHooks.addMarker(
160-
pid,
161-
MARKER_TYPE_BENCHMARK_START,
162-
instrumentWindow.runStart!,
163-
);
164-
InstrumentHooks.addMarker(pid, MARKER_TYPE_BENCHMARK_END, runEnd);
292+
InstrumentHooks.addMarker(pid, MARKER_TYPE_BENCHMARK_START, start);
293+
InstrumentHooks.addMarker(pid, MARKER_TYPE_BENCHMARK_END, end);
165294

166295
InstrumentHooks.stopBenchmark();
167296
InstrumentHooks.setExecutedBenchmark(pid, uri);
168-
instrumentWindow.runStart = null;
169297
}
170298

171299
/**

0 commit comments

Comments
 (0)