|
| 1 | +'use strict' |
| 2 | + |
| 3 | +const wait = require('timers/promises').setTimeout |
| 4 | +const assert = require('assert') |
| 5 | +const common = require('./common') |
| 6 | +const gcTrackerMap = new WeakMap() |
| 7 | +const gcTrackerTag = 'NODE_TEST_COMMON_GC_TRACKER' |
| 8 | + |
| 9 | +/** |
| 10 | + * Installs a garbage collection listener for the specified object. |
| 11 | + * Uses async_hooks for GC tracking, which may affect test functionality. |
| 12 | + * A full setImmediate() invocation passes between a global.gc() call and the listener being invoked. |
| 13 | + * @param {object} obj - The target object to track for garbage collection. |
| 14 | + * @param {object} gcListener - The listener object containing the ongc callback. |
| 15 | + * @param {Function} gcListener.ongc - The function to call when the target object is garbage collected. |
| 16 | + */ |
| 17 | +function onGC (obj, gcListener) { |
| 18 | + const async_hooks = require('async_hooks') |
| 19 | + |
| 20 | + const onGcAsyncHook = async_hooks.createHook({ |
| 21 | + init: common.mustCallAtLeast(function (id, type) { |
| 22 | + if (this.trackedId === undefined) { |
| 23 | + assert.strictEqual(type, gcTrackerTag) |
| 24 | + this.trackedId = id |
| 25 | + } |
| 26 | + }), |
| 27 | + destroy (id) { |
| 28 | + assert.notStrictEqual(this.trackedId, -1) |
| 29 | + if (id === this.trackedId) { |
| 30 | + this.gcListener.ongc() |
| 31 | + onGcAsyncHook.disable() |
| 32 | + } |
| 33 | + } |
| 34 | + }).enable() |
| 35 | + onGcAsyncHook.gcListener = gcListener |
| 36 | + |
| 37 | + gcTrackerMap.set(obj, new async_hooks.AsyncResource(gcTrackerTag)) |
| 38 | + obj = null |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Repeatedly triggers garbage collection until a specified condition is met or a maximum number of attempts is reached. |
| 43 | + * @param {string|Function} [name] - Optional name, used in the rejection message if the condition is not met. |
| 44 | + * @param {Function} condition - A function that returns true when the desired condition is met. |
| 45 | + * @returns {Promise} A promise that resolves when the condition is met, or rejects after 10 failed attempts. |
| 46 | + */ |
| 47 | +function gcUntil (name, condition) { |
| 48 | + if (typeof name === 'function') { |
| 49 | + condition = name |
| 50 | + name = undefined |
| 51 | + } |
| 52 | + return new Promise((resolve, reject) => { |
| 53 | + let count = 0 |
| 54 | + function gcAndCheck () { |
| 55 | + setImmediate(() => { |
| 56 | + count++ |
| 57 | + global.gc() |
| 58 | + if (condition()) { |
| 59 | + resolve() |
| 60 | + } else if (count < 10) { |
| 61 | + gcAndCheck() |
| 62 | + } else { |
| 63 | + reject(name === undefined ? undefined : 'Test ' + name + ' failed') |
| 64 | + } |
| 65 | + }) |
| 66 | + } |
| 67 | + gcAndCheck() |
| 68 | + }) |
| 69 | +} |
| 70 | + |
| 71 | +// This function can be used to check if an object factor leaks or not, |
| 72 | +// but it needs to be used with care: |
| 73 | +// 1. The test should be set up with an ideally small |
| 74 | +// --max-old-space-size or --max-heap-size, which combined with |
| 75 | +// the maxCount parameter can reproduce a leak of the objects |
| 76 | +// created by fn(). |
| 77 | +// 2. This works under the assumption that if *none* of the objects |
| 78 | +// created by fn() can be garbage-collected, the test would crash due |
| 79 | +// to OOM. |
| 80 | +// 3. If *any* of the objects created by fn() can be garbage-collected, |
| 81 | +// it is considered leak-free. The FinalizationRegistry is used to |
| 82 | +// terminate the test early once we detect any of the object is |
| 83 | +// garbage-collected to make the test less prone to false positives. |
| 84 | +// This may be especially important for memory management relying on |
| 85 | +// emphemeron GC which can be inefficient to deal with extremely fast |
| 86 | +// heap growth. |
| 87 | +// Note that this can still produce false positives. When the test using |
| 88 | +// this function still crashes due to OOM, inspect the heap to confirm |
| 89 | +// if a leak is present (e.g. using heap snapshots). |
| 90 | +// The generateSnapshotAt parameter can be used to specify a count |
| 91 | +// interval to create the heap snapshot which may enforce a more thorough GC. |
| 92 | +// This can be tried for code paths that require it for the GC to catch up |
| 93 | +// with heap growth. However this type of forced GC can be in conflict with |
| 94 | +// other logic in V8 such as bytecode aging, and it can slow down the test |
| 95 | +// significantly, so it should be used scarcely and only as a last resort. |
| 96 | +async function checkIfCollectable ( |
| 97 | + fn, maxCount = 4096, generateSnapshotAt = Infinity, logEvery = 128) { |
| 98 | + let anyFinalized = false |
| 99 | + let count = 0 |
| 100 | + |
| 101 | + const f = new FinalizationRegistry(() => { |
| 102 | + anyFinalized = true |
| 103 | + }) |
| 104 | + |
| 105 | + async function createObject () { |
| 106 | + const obj = await fn() |
| 107 | + f.register(obj) |
| 108 | + if (count++ < maxCount && !anyFinalized) { |
| 109 | + setImmediate(createObject, 1) |
| 110 | + } |
| 111 | + // This can force a more thorough GC, but can slow the test down |
| 112 | + // significantly in a big heap. Use it with care. |
| 113 | + if (count % generateSnapshotAt === 0) { |
| 114 | + // XXX(joyeecheung): This itself can consume a bit of JS heap memory, |
| 115 | + // but the other alternative writeHeapSnapshot can run into disk space |
| 116 | + // not enough problems in the CI & be slower depending on file system. |
| 117 | + // Just do this for now as long as it works and only invent some |
| 118 | + // internal voodoo when we absolutely have no other choice. |
| 119 | + require('v8').getHeapSnapshot().pause().read() |
| 120 | + console.log(`Generated heap snapshot at ${count}`) |
| 121 | + } |
| 122 | + if (count % logEvery === 0) { |
| 123 | + console.log(`Created ${count} objects`) |
| 124 | + } |
| 125 | + if (anyFinalized) { |
| 126 | + console.log(`Found finalized object at ${count}, stop testing`) |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + createObject() |
| 131 | +} |
| 132 | + |
| 133 | +// Repeat an operation and give GC some breathing room at every iteration. |
| 134 | +async function runAndBreathe (fn, repeat, waitTime = 20) { |
| 135 | + for (let i = 0; i < repeat; i++) { |
| 136 | + await fn() |
| 137 | + await wait(waitTime) |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +/** |
| 142 | + * This requires --expose-internals. |
| 143 | + * This function can be used to check if an object factory leaks or not by |
| 144 | + * iterating over the heap and count objects with the specified class |
| 145 | + * (which is checked by looking up the prototype chain). |
| 146 | + * @param {(i: number) => number} fn The factory receiving iteration count |
| 147 | + * and returning number of objects created. The return value should be |
| 148 | + * precise otherwise false negatives can be produced. |
| 149 | + * @param {Function} ctor The constructor of the objects being counted. |
| 150 | + * @param {number} count Number of iterations that this check should be done |
| 151 | + * @param {number} waitTime Optional breathing time for GC. |
| 152 | + */ |
| 153 | +async function checkIfCollectableByCounting (fn, ctor, count, waitTime = 20) { |
| 154 | + const { queryObjects } = require('v8') |
| 155 | + const { name } = ctor |
| 156 | + const initialCount = queryObjects(ctor, { format: 'count' }) |
| 157 | + console.log(`Initial count of ${name}: ${initialCount}`) |
| 158 | + let totalCreated = 0 |
| 159 | + for (let i = 0; i < count; ++i) { |
| 160 | + const created = await fn(i) |
| 161 | + totalCreated += created |
| 162 | + console.log(`#${i}: created ${created} ${name}, total ${totalCreated}`) |
| 163 | + await wait(waitTime) // give GC some breathing room. |
| 164 | + const currentCount = queryObjects(ctor, { format: 'count' }) |
| 165 | + const collected = totalCreated - (currentCount - initialCount) |
| 166 | + console.log(`#${i}: counted ${currentCount} ${name}, collected ${collected}`) |
| 167 | + if (collected > 0) { |
| 168 | + console.log(`Detected ${collected} collected ${name}, finish early`) |
| 169 | + return |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + await wait(waitTime) // give GC some breathing room. |
| 174 | + const currentCount = queryObjects(ctor, { format: 'count' }) |
| 175 | + const collected = totalCreated - (currentCount - initialCount) |
| 176 | + console.log(`Last count: counted ${currentCount} ${name}, collected ${collected}`) |
| 177 | + // Some objects with the prototype can be collected. |
| 178 | + if (collected > 0) { |
| 179 | + console.log(`Detected ${collected} collected ${name}`) |
| 180 | + return |
| 181 | + } |
| 182 | + |
| 183 | + throw new Error(`${name} cannot be collected`) |
| 184 | +} |
| 185 | + |
| 186 | +module.exports = { |
| 187 | + checkIfCollectable, |
| 188 | + runAndBreathe, |
| 189 | + checkIfCollectableByCounting, |
| 190 | + onGC, |
| 191 | + gcUntil |
| 192 | +} |
0 commit comments