Skip to content

Commit a0b5488

Browse files
authored
eng: add assertHeap method for memory assertions (#198334)
This adds an `assertHeap` function that can be used in tests. It takes a heap snapshot, and asserts the state of classes in memory. This works in Node and the Electron sandbox, but is a no-op in the browser. Snapshots are process asynchronously and will report failures at the end of the suite. This method should be used sparingly (e.g. once at the end of a suite to ensure nothing leaked before), as gathering a heap snapshot is fairly slow, at least until V8 11.5.130 (https://v8.dev/blog/speeding-up-v8-heap-snapshots). When used, the function will ensure the test has a minimum timeout duration of 20s to avoid immediate failures. It takes options containing a mapping of class names, and assertion functions to run on the number of retained instances of that class. For example: ```ts assertSnapshot({ classes: { ShouldNeverLeak: count => assert.strictEqual(count, 0), SomeSingleton: count => assert(count <= 1), } }); ``` Closes #191920
1 parent 8cac42e commit a0b5488

7 files changed

Lines changed: 181 additions & 1 deletion

File tree

.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@
128128
{
129129
"message": "Expression must be awaited",
130130
"functions": [
131-
"assertSnapshot"
131+
"assertSnapshot",
132+
"assertHeap"
132133
]
133134
}
134135
]

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"@vscode/test-cli": "^0.0.3",
141141
"@vscode/test-electron": "^2.3.5",
142142
"@vscode/test-web": "^0.0.42",
143+
"@vscode/v8-heap-parser": "^0.1.0",
143144
"@vscode/vscode-perf": "^0.0.14",
144145
"ansi-colors": "^3.2.3",
145146
"asar": "^3.0.3",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
7+
declare const __analyzeSnapshotInTests: (currentTest: string, classes: readonly string[]) => Promise<({ done: Promise<number[]>; file: string })>;
8+
9+
let currentTest: Mocha.Test | undefined;
10+
11+
const snapshotsToAssert: ({ counts: Promise<number[]>; file: string; test: string; opts: ISnapshotAssertOptions })[] = [];
12+
13+
setup(function () {
14+
currentTest = this.currentTest;
15+
});
16+
17+
suiteTeardown(async () => {
18+
await Promise.all(snapshotsToAssert.map(async snap => {
19+
const counts = await snap.counts;
20+
21+
const asserts = Object.entries(snap.opts.classes);
22+
if (asserts.length !== counts.length) {
23+
throw new Error(`expected class counts to equal assertions length for ${snap.test}`);
24+
}
25+
26+
for (const [i, [name, doAssert]] of asserts.entries()) {
27+
try {
28+
doAssert(counts[i]);
29+
} catch (e) {
30+
throw new Error(`Unexpected number of ${name} instances (${counts[i]}) after "${snap.test}":\n\n${e.message}\n\nSnapshot saved at: ${snap.file}`);
31+
}
32+
}
33+
}));
34+
35+
snapshotsToAssert.length = 0;
36+
});
37+
38+
export interface ISnapshotAssertOptions {
39+
classes: Record<string, (count: number) => void>;
40+
}
41+
42+
const snapshotMinTime = 20_000;
43+
44+
/**
45+
* Takes a heap snapshot, and asserts the state of classes in memory. This
46+
* works in Node and the Electron sandbox, but is a no-op in the browser.
47+
* Snapshots are process asynchronously and will report failures at the end of
48+
* the suite.
49+
*
50+
* This method should be used sparingly (e.g. once at the end of a suite to
51+
* ensure nothing leaked before), as gathering a heap snapshot is fairly
52+
* slow, at least until V8 11.5.130 (https://v8.dev/blog/speeding-up-v8-heap-snapshots).
53+
*
54+
* Takes options containing a mapping of class names, and assertion functions
55+
* to run on the number of retained instances of that class. For example:
56+
*
57+
* ```ts
58+
* assertSnapshot({
59+
* classes: {
60+
* ShouldNeverLeak: count => assert.strictEqual(count, 0),
61+
* SomeSingleton: count => assert(count <= 1),
62+
* }
63+
*});
64+
* ```
65+
*/
66+
export async function assertHeap(opts: ISnapshotAssertOptions) {
67+
if (!currentTest) {
68+
throw new Error('assertSnapshot can only be used when a test is running');
69+
}
70+
71+
// snapshotting can take a moment, ensure the test timeout is decently long
72+
// so it doesn't immediately fail.
73+
if (currentTest.timeout() < snapshotMinTime) {
74+
currentTest.timeout(snapshotMinTime);
75+
}
76+
77+
if (typeof __analyzeSnapshotInTests === 'undefined') {
78+
return; // running in browser, no-op
79+
}
80+
81+
const { done, file } = await __analyzeSnapshotInTests(currentTest.fullTitle(), Object.keys(opts.classes));
82+
snapshotsToAssert.push({ counts: done, file, test: currentTest.fullTitle(), opts });
83+
}
84+

test/unit/analyzeSnapshot.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
//@ts-check
7+
8+
// note: we use a fork here since we can't make a worker from the renderer process
9+
10+
const { fork } = require('child_process');
11+
const workerData = process.env.SNAPSHOT_WORKER_DATA;
12+
const fs = require('fs');
13+
const { pathToFileURL } = require('url');
14+
15+
if (!workerData) {
16+
const { join } = require('path');
17+
const { tmpdir } = require('os');
18+
19+
exports.takeSnapshotAndCountClasses = async (/** @type string */currentTest, /** @type string[] */ classes) => {
20+
const cleanTitle = currentTest.replace(/[^\w]+/g, '-');
21+
const file = join(tmpdir(), `vscode-test-snap-${cleanTitle}.heapsnapshot`);
22+
23+
if (typeof process.takeHeapSnapshot !== 'function') {
24+
// node.js:
25+
const inspector = require('inspector');
26+
const session = new inspector.Session();
27+
session.connect();
28+
29+
const fd = fs.openSync(file, 'w');
30+
await new Promise((resolve, reject) => {
31+
session.on('HeapProfiler.addHeapSnapshotChunk', (m) => {
32+
fs.writeSync(fd, m.params.chunk);
33+
});
34+
35+
session.post('HeapProfiler.takeHeapSnapshot', null, (err) => {
36+
session.disconnect();
37+
fs.closeSync(fd);
38+
if (err) {
39+
reject(err);
40+
} else {
41+
resolve();
42+
}
43+
});
44+
});
45+
} else {
46+
// electron exposes this nice method for us:
47+
process.takeHeapSnapshot(file);
48+
}
49+
50+
const worker = fork(__filename, {
51+
env: {
52+
...process.env,
53+
SNAPSHOT_WORKER_DATA: JSON.stringify({
54+
path: file,
55+
classes,
56+
})
57+
}
58+
});
59+
60+
const promise = new Promise((resolve, reject) => {
61+
worker.on('message', (/** @type any */msg) => {
62+
if ('err' in msg) {
63+
reject(new Error(msg.err));
64+
} else {
65+
resolve(msg.counts);
66+
}
67+
worker.kill();
68+
});
69+
});
70+
71+
return { done: promise, file: pathToFileURL(file) };
72+
};
73+
} else {
74+
const { path, classes } = JSON.parse(workerData);
75+
const { decode_bytes } = require('@vscode/v8-heap-parser');
76+
77+
fs.promises.readFile(path)
78+
.then(buf => decode_bytes(buf))
79+
.then(graph => graph.get_class_counts(classes))
80+
.then(
81+
counts => process.send({ counts: Array.from(counts) }),
82+
err => process.send({ err: String(err.stack || err) })
83+
);
84+
85+
}

test/unit/electron/renderer.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const glob = require('glob');
6767
const util = require('util');
6868
const bootstrap = require('../../../src/bootstrap');
6969
const coverage = require('../coverage');
70+
const { takeSnapshotAndCountClasses } = require('../analyzeSnapshot');
7071

7172
// Disabled custom inspect. See #38847
7273
if (util.inspect && util.inspect['defaultOptions']) {
@@ -82,6 +83,7 @@ globalThis._VSCODE_PACKAGE_JSON = (require.__$__nodeRequire ?? require)('../../.
8283

8384
// Test file operations that are common across platforms. Used for test infra, namely snapshot tests
8485
Object.assign(globalThis, {
86+
__analyzeSnapshotInTests: takeSnapshotAndCountClasses,
8587
__readFileInTests: path => fs.promises.readFile(path, 'utf-8'),
8688
__writeFileInTests: (path, contents) => fs.promises.writeFile(path, contents),
8789
__readDirInTests: path => fs.promises.readdir(path),

test/unit/node/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const glob = require('glob');
1616
const minimatch = require('minimatch');
1717
const coverage = require('../coverage');
1818
const minimist = require('minimist');
19+
const { takeSnapshotAndCountClasses } = require('../analyzeSnapshot');
1920

2021
/**
2122
* @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; }}
@@ -83,6 +84,7 @@ function main() {
8384

8485
// Test file operations that are common across platforms. Used for test infra, namely snapshot tests
8586
Object.assign(globalThis, {
87+
__analyzeSnapshotInTests: takeSnapshotAndCountClasses,
8688
__readFileInTests: (/** @type {string} */ path) => fs.promises.readFile(path, 'utf-8'),
8789
__writeFileInTests: (/** @type {string} */ path, /** @type {BufferEncoding} */ contents) => fs.promises.writeFile(path, contents),
8890
__readDirInTests: (/** @type {string} */ path) => fs.promises.readdir(path),

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,11 @@
14881488
tar-fs "^2.1.1"
14891489
vscode-uri "^3.0.7"
14901490

1491+
"@vscode/v8-heap-parser@^0.1.0":
1492+
version "0.1.0"
1493+
resolved "https://registry.yarnpkg.com/@vscode/v8-heap-parser/-/v8-heap-parser-0.1.0.tgz#f3fe61ce954cc3dd78ed42e09f865450685e351f"
1494+
integrity sha512-3EvQak7EIOLyIGz+IP9qSwRmP08ZRWgTeoRgAXPVkkDXZ8riqJ7LDtkgx++uHBiJ3MUaSdlUYPZcLFFw7E6zGg==
1495+
14911496
14921497
version "1.0.21"
14931498
resolved "https://registry.yarnpkg.com/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.21.tgz#89b48f293f6aa3341bb888c1118d16ff13b032d3"

0 commit comments

Comments
 (0)