From 08261e3c15cd252a0b4ab68d0ddd4af880a55cd8 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Mon, 31 Dec 2018 11:50:40 -0500 Subject: [PATCH] Change DOM surveyor to time-based processing logic (from chunk-based) The DOM surveyor will now use time-based logic to spread its work over time. This allows the surveying to better scale down on slower devices. Additionally, the DOM surveyor code has been reworked to lower as much as possible memory churning when collating nodes to survey. This rework has been motivated after profiling the "monstrous DOM" seen in the following page: The idea is that making the DOM surveyor efficient on such "monstrous DOM" case should make it efficient everywhere in practice. --- src/js/contentscript.js | 358 +++++++++++++++++++++------------------- 1 file changed, 188 insertions(+), 170 deletions(-) diff --git a/src/js/contentscript.js b/src/js/contentscript.js index 4facff119..e70a7e3cb 100644 --- a/src/js/contentscript.js +++ b/src/js/contentscript.js @@ -142,35 +142,55 @@ vAPI.contentScript = true; // https://github.com/gorhill/uBlock/issues/2147 vAPI.SafeAnimationFrame = function(callback) { - this.fid = this.tid = null; + this.fid = this.tid = undefined; this.callback = callback; - this.boundMacroToMicro = this.macroToMicro.bind(this); }; vAPI.SafeAnimationFrame.prototype = { start: function(delay) { if ( delay === undefined ) { - if ( this.fid === null ) { - this.fid = requestAnimationFrame(this.callback); + if ( this.fid === undefined ) { + this.fid = requestAnimationFrame(( ) => { this.onRAF(); } ); } - if ( this.tid === null ) { - this.tid = vAPI.setTimeout(this.callback, 20000); + if ( this.tid === undefined ) { + this.tid = vAPI.setTimeout(( ) => { this.onSTO(); }, 20000); } return; } - if ( this.fid === null && this.tid === null ) { - this.tid = vAPI.setTimeout(this.boundMacroToMicro, delay); + if ( this.fid === undefined && this.tid === undefined ) { + this.tid = vAPI.setTimeout(( ) => { this.macroToMicro(); }, delay); } }, clear: function() { - if ( this.fid !== null ) { cancelAnimationFrame(this.fid); } - if ( this.tid !== null ) { clearTimeout(this.tid); } - this.fid = this.tid = null; + if ( this.fid !== undefined ) { + cancelAnimationFrame(this.fid); + this.fid = undefined; + } + if ( this.tid !== undefined ) { + clearTimeout(this.tid); + this.tid = undefined; + } }, macroToMicro: function() { - this.tid = null; + this.tid = undefined; this.start(); - } + }, + onRAF: function() { + if ( this.tid !== undefined ) { + clearTimeout(this.tid); + this.tid = undefined; + } + this.fid = undefined; + this.callback(); + }, + onSTO: function() { + if ( this.fid !== undefined ) { + cancelAnimationFrame(this.fid); + this.fid = undefined; + } + this.tid = undefined; + this.callback(); + }, }; /******************************************************************************/ @@ -193,7 +213,6 @@ vAPI.domWatcher = (function() { const safeObserverHandler = function() { //console.time('dom watcher/safe observer handler'); - safeObserverHandlerTimer.clear(); let i = addedNodeLists.length, j = addedNodes.length; while ( i-- ) { @@ -1034,13 +1053,149 @@ vAPI.domSurveyor = (function() { const messaging = vAPI.messaging; const queriedIds = new Set(); const queriedClasses = new Set(); - const pendingIdNodes = { nodes: [], added: [] }; - const pendingClassNodes = { nodes: [], added: [] }; + const maxSurveyNodes = 65536; + const maxSurveyTimeSlice = 4; + const maxSurveyBuffer = 64; let domFilterer, hostname = '', surveyCost = 0; + const pendingNodes = { + nodeLists: [], + buffer: [ + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + ], + j: 0, + accepted: 0, + iterated: 0, + stopped: false, + add: function(nodes) { + if ( nodes.length === 0 || this.accepted >= maxSurveyNodes ) { + return; + } + this.nodeLists.push(nodes); + this.accepted += nodes.length; + }, + next: function() { + if ( this.nodeLists.length === 0 || this.stopped ) { return 0; } + const nodeLists = this.nodeLists; + let ib = 0; + do { + const nodeList = nodeLists[0]; + let j = this.j; + let n = j + maxSurveyBuffer - ib; + if ( n > nodeList.length ) { + n = nodeList.length; + } + for ( let i = j; i < n; i++ ) { + this.buffer[ib++] = nodeList[j++]; + } + if ( j !== nodeList.length ) { + this.j = j; + break; + } + this.j = 0; + this.nodeLists.shift(); + } while ( ib < maxSurveyBuffer && nodeLists.length !== 0 ); + this.iterated += ib; + if ( this.iterated >= maxSurveyNodes ) { + this.nodeLists = []; + this.stopped = true; + //console.info(`domSurveyor> Surveyed a total of ${this.iterated} nodes. Enough.`); + } + return ib; + }, + hasNodes: function() { + return this.nodeLists.length !== 0; + }, + }; + + // Extract all classes/ids: these will be passed to the cosmetic + // filtering engine, and in return we will obtain only the relevant + // CSS selectors. + const reWhitespace = /\s/; + + // https://github.com/gorhill/uBlock/issues/672 + // http://www.w3.org/TR/2014/REC-html5-20141028/infrastructure.html#space-separated-tokens + // http://jsperf.com/enumerate-classes/6 + + const surveyPhase1 = function() { + //console.time('dom surveyor/surveying'); + const t0 = performance.now(); + const rews = reWhitespace; + const ids = []; + const classes = []; + const nodes = pendingNodes.buffer; + const deadline = t0 + maxSurveyTimeSlice; + let qids = queriedIds; + let qcls = queriedClasses; + let processed = 0; + for (;;) { + const n = pendingNodes.next(); + if ( n === 0 ) { break; } + for ( let i = 0; i < n; i++ ) { + const node = nodes[i]; nodes[i] = null; + let v = node.id; + if ( typeof v === 'string' && v.length !== 0 ) { + v = v.trim(); + if ( qids.has(v) === false && v.length !== 0 ) { + ids.push(v); qids.add(v); + } + } + let vv = node.className; + if ( typeof vv === 'string' && vv.length !== 0 ) { + if ( rews.test(vv) === false ) { + if ( qcls.has(vv) === false ) { + classes.push(vv); qcls.add(vv); + } + } else { + vv = node.classList; + let j = vv.length; + while ( j-- ) { + const v = vv[j]; + if ( qcls.has(v) === false ) { + classes.push(v); qcls.add(v); + } + } + } + } + } + processed += n; + if ( performance.now() >= deadline ) { break; } + } + const t1 = performance.now(); + surveyCost += t1 - t0; + //console.info(`domSurveyor> Surveyed ${processed} nodes in ${(t1-t0).toFixed(2)} ms`); + // Phase 2: Ask main process to lookup relevant cosmetic filters. + if ( ids.length !== 0 || classes.length !== 0 ) { + messaging.send( + 'contentscript', + { + what: 'retrieveGenericCosmeticSelectors', + hostname: hostname, + ids: ids, + classes: classes, + exceptions: domFilterer.exceptions, + cost: surveyCost + }, + surveyPhase3 + ); + } else { + surveyPhase3(null); + } + //console.timeEnd('dom surveyor/surveying'); + }; + + const surveyTimer = new vAPI.SafeAnimationFrame(surveyPhase1); + // This is to shutdown the surveyor if result of surveying keeps being // fruitless. This is useful on long-lived web page. I arbitrarily // picked 5 minutes before the surveyor is allowed to shutdown. I also @@ -1085,19 +1240,19 @@ vAPI.domSurveyor = (function() { } } - if ( hasChunk(pendingIdNodes) || hasChunk(pendingClassNodes) ) { - surveyTimer.start(1); - } - - if ( mustCommit ) { - surveyingMissCount = 0; - canShutdownAfter = Date.now() + 300000; - return; - } - - surveyingMissCount += 1; - if ( surveyingMissCount < 256 || Date.now() < canShutdownAfter ) { - return; + if ( pendingNodes.stopped === false ) { + if ( pendingNodes.hasNodes() ) { + surveyTimer.start(1); + } + if ( mustCommit ) { + surveyingMissCount = 0; + canShutdownAfter = Date.now() + 300000; + return; + } + surveyingMissCount += 1; + if ( surveyingMissCount < 256 || Date.now() < canShutdownAfter ) { + return; + } } //console.info('dom surveyor shutting down: too many misses'); @@ -1107,126 +1262,6 @@ vAPI.domSurveyor = (function() { vAPI.domSurveyor = null; }; - // The purpose of "chunkification" is to ensure the surveyor won't unduly - // block the main event loop. - - const hasChunk = function(pending) { - return pending.nodes.length !== 0 || pending.added.length !== 0; - }; - - const addChunk = function(pending, added) { - if ( added.length === 0 ) { return; } - if ( - Array.isArray(added) === false || - pending.added.length === 0 || - Array.isArray(pending.added[0]) === false || - pending.added[0].length >= 1000 - ) { - pending.added.push(added); - } else { - pending.added = pending.added.concat(added); - } - }; - - const nextChunk = function(pending) { - let added = pending.added.length !== 0 ? pending.added.shift() : [], - nodes; - if ( pending.nodes.length === 0 ) { - if ( added.length <= 1000 ) { return added; } - nodes = Array.isArray(added) - ? added - : Array.prototype.slice.call(added); - pending.nodes = nodes.splice(1000); - return nodes; - } - if ( Array.isArray(added) === false ) { - added = Array.prototype.slice.call(added); - } - if ( pending.nodes.length < 1000 ) { - nodes = pending.nodes.concat(added.splice(0, 1000 - pending.nodes.length)); - pending.nodes = added; - } else { - nodes = pending.nodes.splice(0, 1000); - pending.nodes = pending.nodes.concat(added); - } - return nodes; - }; - - // Extract all classes/ids: these will be passed to the cosmetic - // filtering engine, and in return we will obtain only the relevant - // CSS selectors. - const reWhitespace = /\s/; - - // https://github.com/gorhill/uBlock/issues/672 - // http://www.w3.org/TR/2014/REC-html5-20141028/infrastructure.html#space-separated-tokens - // http://jsperf.com/enumerate-classes/6 - - const surveyPhase1 = function() { - //console.time('dom surveyor/surveying'); - surveyTimer.clear(); - const t0 = window.performance.now(); - const rews = reWhitespace; - const ids = []; - let iout = 0; - let qq = queriedIds; - let nodes = nextChunk(pendingIdNodes); - let i = nodes.length; - while ( i-- ) { - const node = nodes[i]; - let v = node.id; - if ( typeof v !== 'string' ) { continue; } - v = v.trim(); - if ( qq.has(v) === false && v.length !== 0 ) { - ids[iout++] = v; qq.add(v); - } - } - const classes = []; - iout = 0; - qq = queriedClasses; - nodes = nextChunk(pendingClassNodes); - i = nodes.length; - while ( i-- ) { - const node = nodes[i]; - let vv = node.className; - if ( typeof vv !== 'string' ) { continue; } - if ( rews.test(vv) === false ) { - if ( qq.has(vv) === false && vv.length !== 0 ) { - classes[iout++] = vv; qq.add(vv); - } - } else { - vv = node.classList; - let j = vv.length; - while ( j-- ) { - let v = vv[j]; - if ( qq.has(v) === false ) { - classes[iout++] = v; qq.add(v); - } - } - } - } - surveyCost += window.performance.now() - t0; - // Phase 2: Ask main process to lookup relevant cosmetic filters. - if ( ids.length !== 0 || classes.length !== 0 ) { - messaging.send( - 'contentscript', - { - what: 'retrieveGenericCosmeticSelectors', - hostname: hostname, - ids: ids.join('\n'), - classes: classes.join('\n'), - exceptions: domFilterer.exceptions, - cost: surveyCost - }, - surveyPhase3 - ); - } else { - surveyPhase3(null); - } - //console.timeEnd('dom surveyor/surveying'); - }; - - const surveyTimer = new vAPI.SafeAnimationFrame(surveyPhase1); - const domWatcherInterface = { onDOMCreated: function() { if ( @@ -1244,38 +1279,21 @@ vAPI.domSurveyor = (function() { } //console.time('dom surveyor/dom layout created'); domFilterer = vAPI.domFilterer; - addChunk(pendingIdNodes, document.querySelectorAll('[id]')); - addChunk(pendingClassNodes, document.querySelectorAll('[class]')); + pendingNodes.add(document.querySelectorAll('[id],[class]')); surveyTimer.start(); //console.timeEnd('dom surveyor/dom layout created'); }, onDOMChanged: function(addedNodes) { if ( addedNodes.length === 0 ) { return; } //console.time('dom surveyor/dom layout changed'); - const idNodes = []; - let iid = 0; - const classNodes = []; - let iclass = 0; let i = addedNodes.length; while ( i-- ) { const node = addedNodes[i]; - idNodes[iid++] = node; - classNodes[iclass++] = node; + pendingNodes.add([ node ]); if ( node.childElementCount === 0 ) { continue; } - let nodeList = node.querySelectorAll('[id]'); - let j = nodeList.length; - while ( j-- ) { - idNodes[iid++] = nodeList[j]; - } - nodeList = node.querySelectorAll('[class]'); - j = nodeList.length; - while ( j-- ) { - classNodes[iclass++] = nodeList[j]; - } + pendingNodes.add(node.querySelectorAll('[id],[class]')); } - if ( idNodes.length !== 0 || classNodes.lengh !== 0 ) { - addChunk(pendingIdNodes, idNodes); - addChunk(pendingClassNodes, classNodes); + if ( pendingNodes.hasNodes() ) { surveyTimer.start(1); } //console.timeEnd('dom surveyor/dom layout changed');