allocation-tracker.js (17617B)
1 /* this source code form is subject to the terms of the mozilla public 2 * license, v. 2.0. if a copy of the mpl was not distributed with this 3 * file, you can obtain one at http://mozilla.org/mpl/2.0/. */ 4 5 /* 6 * This file helps tracking Javascript object allocations. 7 * It is only included in local builds as a debugging helper. 8 * 9 * It is typicaly used when running DevTools tests (either mochitests or DAMP). 10 * To use it, you need to set the following environment variable: 11 * DEBUG_DEVTOOLS_ALLOCATIONS="normal" 12 * This will only print the number of JS objects created during your test. 13 * DEBUG_DEVTOOLS_ALLOCATIONS="verbose" 14 * This will print the allocation sites of all the JS objects created during your 15 * test. i.e. from which files and lines the objects have been created. 16 * In both cases, look for "DEVTOOLS ALLOCATION" in your terminal to see tracker's 17 * output. 18 * 19 * But you can also import it from your test script if you want to focus on one 20 * particular piece of code: 21 * const { allocationTracker } = 22 * require("devtools/shared/test-helpers/allocation-tracker"); 23 * // Calling `allocationTracker` will immediately start recording allocations 24 * let tracker = allocationTracker(); 25 * 26 * // Do something 27 * 28 * // If you want to log all the allocation sites, call this method: 29 * tracker.logAllocationLog(); 30 * // Or, if you want to only print the number of objects being allocated, call this: 31 * tracker.logCount(); 32 * // Once you are done, stop the tracker as it slow down execution a lot. 33 * tracker.stop(); 34 */ 35 36 "use strict"; 37 38 const MemoryReporter = Cc["@mozilla.org/memory-reporter-manager;1"].getService( 39 Ci.nsIMemoryReporterManager 40 ); 41 42 const global = Cu.getGlobalForObject(this); 43 const { addDebuggerToGlobal } = ChromeUtils.importESModule( 44 "resource://gre/modules/jsdebugger.sys.mjs", 45 { global: "contextual" } 46 ); 47 addDebuggerToGlobal(global); 48 49 /** 50 * Start recording JS object allocations. 51 * 52 * @param Object watchGlobal 53 * One global object to observe. Only allocation made from this global 54 * will be recorded. 55 * @param Boolean watchAllGlobals 56 * If true, allocations from everywhere are going to be recorded. 57 * @param Boolean watchAllGlobals 58 * If true, only allocations made from DevTools contexts are going to be recorded. 59 */ 60 exports.allocationTracker = function ({ 61 watchGlobal, 62 watchAllGlobals, 63 watchDevToolsGlobals, 64 } = {}) { 65 logTracker("Start logging allocations"); 66 let dbg = new global.Debugger(); 67 68 // Enable allocation site tracking, to have the stack for each allocation 69 dbg.memory.trackingAllocationSites = true; 70 // Force saving *all* the allocation sites 71 dbg.memory.allocationSamplingProbability = 1.0; 72 // Bumps the default buffer size, which may prevent recording all the test allocations 73 dbg.memory.maxAllocationsLogLength = 5000000; 74 75 let acceptGlobal; 76 if (watchGlobal) { 77 acceptGlobal = () => false; 78 dbg.addDebuggee(watchGlobal); 79 } else if (watchAllGlobals) { 80 acceptGlobal = () => true; 81 } else if (watchDevToolsGlobals) { 82 // Only accept globals related to DevTools 83 const builtinGlobal = require("resource://devtools/shared/loader/builtin-modules.js"); 84 acceptGlobal = g => { 85 // self-hosting-global crashes when trying to call unsafeDereference 86 if (g.class == "self-hosting-global") { 87 logTracker("NEW GLOBAL: - : " + g.class); 88 return false; 89 } 90 let ref = g.unsafeDereference(); 91 // If we are on a toolbox's iframe, typically each panel's iframe 92 // retrieve the toolbox iframe via window.top 93 if (g.class == "Window" && ref.top) { 94 ref = ref.top; 95 } 96 const location = Cu.getRealmLocation(ref); 97 let accept = !!location.match(/devtools/i); 98 99 // Also ignore the dedicated Sandbox used to spawn builtin-modules, 100 // as well as its internal ChromeDebugger Sandbox. 101 // We ignore the global used by the dedicated loader used to load 102 // the allocation-tracker module. 103 if ( 104 ref == Cu.getGlobalForObject(builtinGlobal) || 105 ref == Cu.getGlobalForObject(builtinGlobal.modules.ChromeDebugger) 106 ) { 107 accept = false; 108 } 109 110 logTracker("NEW GLOBAL: " + (accept ? "+" : "-") + " : " + location); 111 return accept; 112 }; 113 } 114 115 // Watch all globals 116 if (watchAllGlobals || watchDevToolsGlobals) { 117 dbg.addAllGlobalsAsDebuggees(); 118 119 for (const g of dbg.getDebuggees()) { 120 if (!acceptGlobal(g)) { 121 dbg.removeDebuggee(g); 122 } 123 } 124 } 125 126 // Remove this global to ignore all its object/JS 127 dbg.removeDebuggee(global); 128 129 // addAllGlobalsAsDebuggees won't automatically track new ones, 130 // so ensure tracking all new globals 131 dbg.onNewGlobalObject = function (g) { 132 if (acceptGlobal(g)) { 133 dbg.addDebuggee(g); 134 } 135 }; 136 137 return { 138 get overflowed() { 139 return dbg.memory.allocationsLogOverflowed; 140 }, 141 142 async startRecordingAllocations(debug_allocations) { 143 // Do a first pass of GC, to ensure all to-be-freed objects from the first run 144 // are really freed. 145 // We have to temporarily disable allocation-site recording in order to ensure 146 // freeing everything and especially avoid retaining objects in the allocation-log 147 // related to `drainAllocationLog` feature. 148 dbg.memory.allocationSamplingProbability = 0.0; 149 // Also force clearing the allocation log in order to prevent holding alive globals 150 // which have been destroyed before we start recording 151 this.flushAllocations(); 152 await this.doGC(); 153 dbg.memory.allocationSamplingProbability = 1.0; 154 155 // Measure the current process memory usage 156 const memory = this.getAllocatedMemory(); 157 158 // Then, record how many objects were already allocated, which should not be declared 159 // as potential leaks. For ex, there is all the modules already loaded 160 // in the main DevTools loader. 161 const objects = this.stillAllocatedObjects(); 162 163 // Flush the allocations so that the next call to logAllocationLog 164 // ignore allocations which happened before this call. 165 if (debug_allocations == "allocations") { 166 this.flushAllocations(); 167 } 168 169 // Retrieve all allocation sites of all the objects already allocated. 170 // So that we can ignore them when we stop the record. 171 const allocations = 172 debug_allocations == "leaks" ? this.getAllAllocations() : null; 173 174 this.data = { memory, objects, allocations }; 175 return this.data; 176 }, 177 178 async stopRecordingAllocations(debug_allocations) { 179 // We have to flush the allocation log in order to prevent leaking some objects 180 // being hold in memory solely by their allocation-site (i.e. `SavedFrame` in `Debugger::allocationsLog`) 181 if (debug_allocations != "allocations") { 182 this.flushAllocations(); 183 } 184 185 // In the content process we watch for all globals. 186 // Disable allocation record immediately, as we get some allocation reported by the allocation-tracker itself. 187 if (watchAllGlobals) { 188 dbg.memory.allocationSamplingProbability = 0.0; 189 } 190 191 // Before computing allocations, re-do some GCs in order to free all what is to-be-freed. 192 await this.doGC(); 193 194 // If we are in the parent process, we watch only for devtools globals. 195 // So we can more safely assert that no allocation occured while doing the GCs. 196 // If means that the test we are recording is having pending operation which aren't properly recorded. 197 if (!watchAllGlobals) { 198 const allocations = dbg.memory.drainAllocationsLog(); 199 if (allocations.length) { 200 this.logAllocationLog( 201 allocations, 202 "Allocation that happened during the GC" 203 ); 204 console.error( 205 "Allocation happened during the GC. Are you waiting correctly before calling stopRecordingAllocations?" 206 ); 207 } 208 } 209 210 const memory = this.getAllocatedMemory(); 211 const objects = this.stillAllocatedObjects(); 212 213 let leaks; 214 if (debug_allocations == "allocations") { 215 this.logAllocationLog(); 216 } else if (debug_allocations == "leaks") { 217 leaks = this.logAllocationSitesDiff(this.data.allocations); 218 } 219 220 return { 221 objectsWithoutStack: 222 objects.objectsWithoutStack - this.data.objects.objectsWithoutStack, 223 objectsWithStack: 224 objects.objectsWithStack - this.data.objects.objectsWithStack, 225 memory: memory - this.data.memory, 226 leaks, 227 }; 228 }, 229 230 /** 231 * Return the collection of currently allocated JS Objects. 232 * 233 * This returns an object whose structure is documented in logAllocationSites. 234 */ 235 getAllAllocations() { 236 const sensus = dbg.memory.takeCensus({ 237 breakdown: { by: "allocationStack" }, 238 }); 239 const sources = {}; 240 for (const [k, v] of sensus.entries()) { 241 const src = k.source || "UNKNOWN"; 242 const line = k.line || "?"; 243 const count = v.count; 244 245 let item = sources[src]; 246 if (!item) { 247 item = sources[src] = { count: 0, lines: {} }; 248 } 249 item.count += count; 250 if (line != -1) { 251 if (!item.lines[line]) { 252 item.lines[line] = 0; 253 } 254 item.lines[line] += count; 255 } 256 } 257 return sources; 258 }, 259 260 /** 261 * Substract count of `previousSources` from `newSources`. 262 * This help know which allocations where done between `previousSources` and `newSources` records, 263 * and, are still allocated. 264 * 265 * The structure of source objects is documented in logAllocationSites. 266 */ 267 sourcesDiff(previousSources, newSources) { 268 for (const src in previousSources) { 269 const previousItem = previousSources[src]; 270 const item = newSources[src]; 271 if (!item) { 272 continue; 273 } 274 item.count -= previousItem.count; 275 276 for (const line in previousItem.lines) { 277 const count = previousItem.lines[line]; 278 if (line != -1) { 279 if (!item.lines[line]) { 280 continue; 281 } 282 item.lines[line] -= count; 283 } 284 } 285 } 286 }, 287 288 /** 289 * Print to stdout data about all recorded allocations 290 * 291 * It prints an array of allocations per file, sorted by files allocating the most 292 * objects. And get detail of allocation per line. 293 * 294 * [{ src: "chrome://devtools/content/framework/toolbox.js", 295 * count: 210, // Total # of allocs for toolbox.js 296 * lines: [ 297 * "10: 200", // toolbox.js allocation 200 objects on line 10 298 * "124: 10 299 * ] 300 * }, 301 * { src: "chrome://devtools/content/inspector/inspector.js", 302 * count: 12, 303 * lines: [ 304 * "20: 12", 305 * ] 306 * }] 307 * 308 * @param first Number 309 * Retrieve only the top $first script allocation the most 310 * objects 311 */ 312 logAllocationSites(message, sources, { first = 1000 } = {}) { 313 const allocationList = Object.entries(sources) 314 // Sort by number of total object 315 .sort(([, itemA], [, itemB]) => itemB.count - itemA.count) 316 // Keep only the first n-th sources, with the most allocations 317 .filter((_, i) => i < first) 318 .map(([src, item]) => { 319 const lines = []; 320 Object.entries(item.lines) 321 // Filter out lines where we only freed objects 322 .filter(([, count]) => count > 0) 323 .sort(([lineA, countA], [lineB, countB]) => { 324 if (countA != countB) { 325 return countB - countA; 326 } 327 return lineB - lineA; 328 }) 329 .forEach(([line, count]) => { 330 // Compress the data to make it readable on stdout 331 lines.push(line + ": " + count); 332 }); 333 return { src, count: item.count, lines }; 334 }) 335 // Filter out modules where we only freed objects 336 .filter(({ count }) => count > 0); 337 logTracker( 338 "DEVTOOLS ALLOCATION: " + 339 message + 340 ":\n" + 341 JSON.stringify(allocationList, null, 2) 342 ); 343 return allocationList; 344 }, 345 346 /** 347 * This method requires a previous call to getAllAllocations 348 * and will print only the allocation sites which are still allocated. 349 * Usage: 350 * const previousSources = this.getAllAllocations(); 351 * ... exercice something, which may leak ... 352 * this.logAllocationSitesDiff(previousSources); 353 */ 354 logAllocationSitesDiff(previousSources) { 355 const newSources = this.getAllAllocations(); 356 this.sourcesDiff(previousSources, newSources); 357 return this.logAllocationSites("allocations which leaked", newSources); 358 }, 359 360 /** 361 * Convert allocation structure coming out from Memory API's `drainAllocationsLog()` 362 * to source structure documented in logAllocationSites. 363 */ 364 allocationsToSources(allocations) { 365 const sources = {}; 366 for (const alloc of allocations) { 367 const { frame } = alloc; 368 let src = "UNKNOWN"; 369 let line = -1; 370 try { 371 if (frame) { 372 src = frame.source || "UNKNOWN"; 373 line = frame.line || -1; 374 } 375 } catch (e) { 376 // For some frames accessing source throws 377 } 378 let item = sources[src]; 379 if (!item) { 380 item = sources[src] = { count: 0, lines: {} }; 381 } 382 item.count++; 383 if (line != -1) { 384 if (!item.lines[line]) { 385 item.lines[line] = 0; 386 } 387 item.lines[line]++; 388 } 389 } 390 return sources; 391 }, 392 393 /** 394 * This method will log all the allocations that happened since the last call 395 * to this method -or- to `flushAllocations`. 396 * Reported allocations may have been freed. 397 * Use `logAllocationSitesDiff` to know what hasn't been freed. 398 */ 399 logAllocationLog(allocations, msg = "") { 400 if (!allocations) { 401 allocations = dbg.memory.drainAllocationsLog(); 402 } 403 const sources = this.allocationsToSources(allocations); 404 return this.logAllocationSites( 405 msg 406 ? msg 407 : "all allocations (which may be freed or are still allocated)", 408 sources 409 ); 410 }, 411 412 logCount() { 413 logTracker( 414 "DEVTOOLS ALLOCATION: Javascript object allocations: " + 415 this.countAllocations() 416 ); 417 }, 418 419 countAllocations() { 420 // Fetch all allocation sites from Debugger API 421 const allocations = dbg.memory.drainAllocationsLog(); 422 return allocations.length; 423 }, 424 425 /** 426 * Reset the allocation log, so that the next call to logAllocationLog/drainAllocationsLog 427 * will report all allocations which happened after this call to flushAllocations. 428 */ 429 flushAllocations() { 430 dbg.memory.drainAllocationsLog(); 431 }, 432 433 /** 434 * Compute the live count of object currently allocated. 435 * 436 * `objects` attribute will count all the objects, 437 * while `objectsWithNoStack` will report how many are missing allocation site/stack. 438 */ 439 stillAllocatedObjects() { 440 const sensus = dbg.memory.takeCensus({ 441 breakdown: { by: "allocationStack" }, 442 }); 443 let objectsWithStack = 0; 444 let objectsWithoutStack = 0; 445 for (const [k, v] of sensus.entries()) { 446 // Objects with missing stack will all be keyed under "noStack" string, 447 // while all others will have a stack object as key. 448 if (k === "noStack") { 449 objectsWithoutStack += v.count; 450 } else { 451 objectsWithStack += v.count; 452 } 453 } 454 return { objectsWithStack, objectsWithoutStack }; 455 }, 456 457 /** 458 * Reports the amount of OS memory used by the current process. 459 */ 460 getAllocatedMemory() { 461 return MemoryReporter.residentUnique; 462 }, 463 464 async doGC() { 465 // In order to get stable results, we really have to do 3 GC attempts 466 // *and* do wait for 1s between each GC. 467 const numCycles = 3; 468 for (let i = 0; i < numCycles; i++) { 469 Cu.forceGC(); 470 Cu.forceCC(); 471 await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); 472 473 // eslint-disable-next-line mozilla/no-arbitrary-setTimeout 474 await new Promise(resolve => setTimeout(resolve, 1000)); 475 } 476 477 // Also call minimizeMemoryUsage as that's the only way to purge JIT cache. 478 // CachedIR objects (JIT related objects) are ultimately leading to keep 479 // all transient globals in memory. For some reason, when enabling trackingAllocationSites=true 480 // we compute stack traces (SavedFrame) for each object being allocated. 481 // This either create new CachedIR -or- force holding alive existing CachedIR 482 // and CachedIR itself hold strong references to the transient globals. 483 // See bug 1733480. 484 await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); 485 }, 486 487 /** 488 * Return the absolute file path to a memory snapshot. 489 * This is used to compute dominator trees in `traceObjects`. 490 */ 491 getSnapshotFile() { 492 return ChromeUtils.saveHeapSnapshot({ debugger: dbg }); 493 }, 494 495 stop() { 496 logTracker("Stop logging allocations"); 497 dbg.onNewGlobalObject = undefined; 498 dbg.removeAllDebuggees(); 499 dbg = null; 500 }, 501 }; 502 }; 503 504 function logTracker(message) { 505 dump(` \x1b[2m[TRACKER]\x1b[0m ${message}\n`); 506 }