Skip to content

Commit 7e502fc

Browse files
test: added stack perf case
1 parent 5505bb2 commit 7e502fc

5 files changed

Lines changed: 169 additions & 0 deletions

File tree

benchmark/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export default function register(bench, { caseName, caseDir, fixtureDir }) {
7979
| ------------------------- | ------------------------------------------------------------------------------------------------------------ |
8080
| `realistic-midsize` | Mixed batch of relative/bare/scoped/exports/nested-`node_modules` requests against a synthetic mid-size tree |
8181
| `pathological-deep-stack` | 50-deep alias chain, specifically stresses the `doResolve` recursion-check path |
82+
| `stack-churn` | Several independent depth-60 alias chains — stresses `doResolve`'s per-level stack-allocation pressure |
8283
| `alias-realistic` | Webpack-style `@/components`, `@utils`, `~` aliases — AliasPlugin with a realistic number of entries |
8384
| `alias-field` | `browser` field remapping (AliasFieldPlugin), including the `false`/ignored branch |
8485
| `exports-field` | Package with nested condition maps and wildcard subpath exports, run under both `require` and `import` |
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "stack-churn-fixture",
3+
"version": "1.0.0"
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = "target";
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* stack-churn
3+
*
4+
* Measures the allocation pressure of `doResolve`'s recursion-tracking
5+
* stack across many moderately-deep alias rewrites.
6+
*
7+
* Every level of alias rewriting re-enters the resolver pipeline via
8+
* `doResolve`, which has to extend the stack used to detect cycles.
9+
* On the `Set<string>`-clone baseline this means `new Set(parent)` per
10+
* level — a fresh hash-table allocation and an O(n) copy on every hook
11+
* re-entry. Under a long-running async workload the resulting GC churn
12+
* is what actually shows up in the numbers (look at p99 / rme when
13+
* running this case: the Set-clone baseline has markedly higher
14+
* variance from GC pauses).
15+
*
16+
* Unlike `pathological-deep-stack` (one very long chain), this case
17+
* fans out across several independent chains so the stacks don't all
18+
* share a common long prefix — closer in shape to large real-world
19+
* alias configs where the resolver sees many unrelated but non-trivial
20+
* stacks.
21+
*/
22+
23+
import fs from "fs";
24+
import enhanced from "../../../lib/index.js";
25+
26+
const { ResolverFactory, CachedInputFileSystem } = enhanced;
27+
28+
const CHAIN_COUNT = 4;
29+
const CHAIN_DEPTH = 60;
30+
const RESOLVES_PER_ITER = 20;
31+
32+
/**
33+
* Build CHAIN_COUNT independent alias chains, each of length CHAIN_DEPTH:
34+
* c0-0 -> c0-1 -> ... -> c0-(n-1) -> ./target
35+
* c1-0 -> c1-1 -> ... -> c1-(n-1) -> ./target
36+
* ...
37+
* so every top-level resolve forces CHAIN_DEPTH `doResolve` re-entries.
38+
* @returns {Array<{name: string, alias: string}>} alias list
39+
*/
40+
function buildChains() {
41+
const aliases = [];
42+
for (let c = 0; c < CHAIN_COUNT; c++) {
43+
for (let i = 0; i < CHAIN_DEPTH - 1; i++) {
44+
aliases.push({ name: `c${c}-${i}`, alias: `c${c}-${i + 1}` });
45+
}
46+
aliases.push({ name: `c${c}-${CHAIN_DEPTH - 1}`, alias: "./target" });
47+
}
48+
return aliases;
49+
}
50+
51+
/**
52+
* @param {import('tinybench').Bench} bench
53+
* @param {{ fixtureDir: string }} ctx
54+
*/
55+
export default function register(bench, { fixtureDir }) {
56+
const fileSystem = new CachedInputFileSystem(fs, 4000);
57+
const aliases = buildChains();
58+
59+
const resolver = ResolverFactory.createResolver({
60+
fileSystem,
61+
extensions: [".js"],
62+
alias: aliases,
63+
});
64+
65+
// Fixed request list (no randomness — CodSpeed requires deterministic work).
66+
const requests = [];
67+
for (let i = 0; i < RESOLVES_PER_ITER; i++) {
68+
requests.push(`c${i % CHAIN_COUNT}-0`);
69+
}
70+
71+
const resolve = (req) =>
72+
new Promise((resolve, reject) => {
73+
resolver.resolve({}, fixtureDir, req, {}, (err, result) => {
74+
if (err) return reject(err);
75+
if (!result) return reject(new Error(`no result for ${req}`));
76+
resolve(result);
77+
});
78+
});
79+
80+
bench.add(
81+
`stack-churn: ${CHAIN_COUNT}x${CHAIN_DEPTH} alias chains, ${RESOLVES_PER_ITER} resolves`,
82+
async () => {
83+
for (const req of requests) {
84+
await resolve(req);
85+
}
86+
},
87+
);
88+
}

test/resolve-context-stack.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use strict";
2+
3+
const fs = require("fs");
4+
const path = require("path");
5+
const { CachedInputFileSystem, ResolverFactory } = require("../");
6+
7+
const nodeFileSystem = new CachedInputFileSystem(fs, 4000);
8+
9+
const fixture = path.resolve(__dirname, "fixtures", "extensions");
10+
11+
describe("resolveContext.stack", () => {
12+
const resolver = ResolverFactory.createResolver({
13+
extensions: [".ts", ".js"],
14+
fileSystem: nodeFileSystem,
15+
});
16+
17+
it("should resolve when no stack is supplied", (done) => {
18+
resolver.resolve({}, fixture, "./foo", {}, (err, result) => {
19+
if (err) return done(err);
20+
expect(result).toBeTruthy();
21+
done();
22+
});
23+
});
24+
25+
it("should resolve when an empty Set is supplied as stack", (done) => {
26+
resolver.resolve(
27+
{},
28+
fixture,
29+
"./foo",
30+
{ stack: new Set() },
31+
(err, result) => {
32+
if (err) return done(err);
33+
expect(result).toBeTruthy();
34+
done();
35+
},
36+
);
37+
});
38+
39+
it("should resolve when a non-empty Set is supplied as stack", (done) => {
40+
// The values below are arbitrary tokens that must not collide with any
41+
// real `resolve` step. Providing them exercises the code path where a
42+
// caller pre-seeds the recursion-tracking stack.
43+
resolver.resolve(
44+
{},
45+
fixture,
46+
"./foo",
47+
{ stack: new Set(["custom-entry-1", "custom-entry-2"]) },
48+
(err, result) => {
49+
if (err) return done(err);
50+
expect(result).toBeTruthy();
51+
done();
52+
},
53+
);
54+
});
55+
56+
it("should detect recursion against entries pre-seeded in the stack", (done) => {
57+
// The first stack entry that `resolve` pushes for this request is
58+
// `resolve: (…fixture…) ./foo`. Pre-seeding an identical string in
59+
// the context must trigger the recursion guard and abort the resolve.
60+
const preSeededEntry = `resolve: (${fixture}) ./foo`;
61+
resolver.resolve(
62+
{},
63+
fixture,
64+
"./foo",
65+
{ stack: new Set([preSeededEntry]) },
66+
(err) => {
67+
expect(err).toBeTruthy();
68+
expect(
69+
/** @type {Error & { recursion?: boolean }} */ (err).recursion,
70+
).toBe(true);
71+
done();
72+
},
73+
);
74+
});
75+
});

0 commit comments

Comments
 (0)