From 79c6bbc1891d1f61e7950f10ce6511529f068003 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Sat, 13 Aug 2022 08:54:32 -0700 Subject: [PATCH] clearStack optimizations * Avoid setTimeout in Node, because we don't need the overhead there. * Still call setTimeout in browsers to prevent the tab from being killed. * Use queueMicrotask in Safari, because it's dramatically faster than MessageChannel there. * Continue to use MessageChannel in other supported browsers becuase it's somewhat faster than queueMicrotask there. * Don't use setImmediate any more because there's a faster alternative in all supported envs. In jasmine-core's own test suite, this yields a roughly 50-70% speedup in Node, ~20% in Edge, and 75-90%(!) in Safari. --- lib/jasmine-core/jasmine.js | 72 ++++--- spec/core/ClearStackSpec.js | 326 ++++++++++++++++++------------- spec/core/integration/EnvSpec.js | 39 +++- src/core/ClearStack.js | 72 ++++--- 4 files changed, 322 insertions(+), 187 deletions(-) diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 6a429e53..534da5df 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -2789,8 +2789,32 @@ getJasmineRequireObj().CallTracker = function(j$) { getJasmineRequireObj().clearStack = function(j$) { const maxInlineCallCount = 10; - function messageChannelImpl(global, setTimeout) { - const channel = new global.MessageChannel(); + function browserQueueMicrotaskImpl(global) { + const { setTimeout, queueMicrotask } = global; + let currentCallCount = 0; + return function clearStack(fn) { + currentCallCount++; + + if (currentCallCount < maxInlineCallCount) { + queueMicrotask(fn); + } else { + currentCallCount = 0; + setTimeout(fn); + } + }; + } + + function nodeQueueMicrotaskImpl(global) { + const { queueMicrotask } = global; + + return function(fn) { + queueMicrotask(fn); + }; + } + + function messageChannelImpl(global) { + const { MessageChannel, setTimeout } = global; + const channel = new MessageChannel(); let head = {}; let tail = head; @@ -2801,7 +2825,7 @@ getJasmineRequireObj().clearStack = function(j$) { delete head.task; if (taskRunning) { - global.setTimeout(task, 0); + setTimeout(task, 0); } else { try { taskRunning = true; @@ -2827,29 +2851,31 @@ getJasmineRequireObj().clearStack = function(j$) { } function getClearStack(global) { - let currentCallCount = 0; - const realSetTimeout = global.setTimeout; - const setTimeoutImpl = function clearStack(fn) { - Function.prototype.apply.apply(realSetTimeout, [global, [fn, 0]]); - }; + const NODE_JS = + global.process && + global.process.versions && + typeof global.process.versions.node === 'string'; - if (j$.isFunction_(global.setImmediate)) { - const realSetImmediate = global.setImmediate; - return function(fn) { - currentCallCount++; + const SAFARI = + global.navigator && + /^((?!chrome|android).)*safari/i.test(global.navigator.userAgent); - if (currentCallCount < maxInlineCallCount) { - realSetImmediate(fn); - } else { - currentCallCount = 0; - - setTimeoutImpl(fn); - } - }; - } else if (!j$.util.isUndefined(global.MessageChannel)) { - return messageChannelImpl(global, setTimeoutImpl); + if (NODE_JS) { + // Unlike browsers, Node doesn't require us to do a periodic setTimeout + // so we avoid the overhead. + return nodeQueueMicrotaskImpl(global); + } else if ( + SAFARI || + j$.util.isUndefined(global.MessageChannel) /* tests */ + ) { + // queueMicrotask is dramatically faster than MessageChannel in Safari. + // Some of our own integration tests provide a mock queueMicrotask in all + // environments because it's simpler to mock than MessageChannel. + return browserQueueMicrotaskImpl(global); } else { - return setTimeoutImpl; + // MessageChannel is faster than queueMicrotask in supported browsers + // other than Safari. + return messageChannelImpl(global); } } diff --git a/spec/core/ClearStackSpec.js b/spec/core/ClearStackSpec.js index 1cfaf6ad..626969f5 100644 --- a/spec/core/ClearStackSpec.js +++ b/spec/core/ClearStackSpec.js @@ -9,160 +9,216 @@ describe('ClearStack', function() { }); }); - it('uses setImmediate when available', function() { - const setImmediate = jasmine - .createSpy('setImmediate') - .and.callFake(function(fn) { - fn(); - }), - global = { setImmediate: setImmediate }, - clearStack = jasmineUnderTest.getClearStack(global); - let called = false; + describe('in Safari', function() { + usesQueueMicrotaskWithSetTimeout(function() { + return { + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.0.8 (KHTML, like Gecko) Version/15.1 Safari/605.0.8' + }, + // queueMicrotask should be used even though MessageChannel is present + MessageChannel: fakeMessageChannel + }; + }); + }); - clearStack(function() { - called = true; + describe('in browsers other than Safari', function() { + usesMessageChannel(function() { + return { + navigator: { + // Chrome's user agent string contains "Safari" so it's a good test case + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' + } + }; }); - expect(called).toBe(true); - expect(setImmediate).toHaveBeenCalled(); + describe('when MessageChannel is unavailable', function() { + usesQueueMicrotaskWithSetTimeout(function() { + return { + navigator: { + userAgent: 'CERN-LineMode/2.15 libwww/2.17b3', + MessageChannel: undefined + } + }; + }); + }); }); - it('uses setTimeout instead of setImmediate every 10 calls to make sure we release the CPU', function() { - const setImmediate = jasmine.createSpy('setImmediate'), - setTimeout = jasmine.createSpy('setTimeout'), - global = { setImmediate: setImmediate, setTimeout: setTimeout }, - clearStack = jasmineUnderTest.getClearStack(global); - - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - - expect(setImmediate).toHaveBeenCalled(); - expect(setTimeout).not.toHaveBeenCalled(); - - clearStack(function() {}); - expect(setImmediate.calls.count()).toEqual(9); - expect(setTimeout.calls.count()).toEqual(1); - - clearStack(function() {}); - expect(setImmediate.calls.count()).toEqual(10); - expect(setTimeout.calls.count()).toEqual(1); - }); - - it('uses MessageChannels when available', function() { - const fakeChannel = { - port1: {}, - port2: { - postMessage: function() { - fakeChannel.port1.onmessage(); + describe('in Node', function() { + usesQueueMicrotaskWithoutSetTimeout(function() { + return { + process: { + versions: { + node: '3.1415927' } } - }, - global = { - MessageChannel: function() { - return fakeChannel; - } - }, - clearStack = jasmineUnderTest.getClearStack(global); - let called = false; + }; + }); + }); - clearStack(function() { - called = true; + function usesMessageChannel(makeGlobal) { + it('uses MessageChannel', function() { + const global = { + ...makeGlobal(), + MessageChannel: fakeMessageChannel + }; + const clearStack = jasmineUnderTest.getClearStack(global); + let called = false; + + clearStack(function() { + called = true; + }); + + expect(called).toBe(true); }); - expect(called).toBe(true); - }); - - it('uses setTimeout instead of MessageChannel every 10 calls to make sure we release the CPU', function() { - const fakeChannel = { - port1: {}, - port2: { - postMessage: jasmine - .createSpy('postMessage') - .and.callFake(function() { - fakeChannel.port1.onmessage(); - }) - } - }, - setTimeout = jasmine.createSpy('setTimeout'), - global = { + it('uses setTimeout instead of MessageChannel every 10 calls to make sure we release the CPU', function() { + const fakeChannel = fakeMessageChannel(); + spyOn(fakeChannel.port2, 'postMessage'); + const setTimeout = jasmine.createSpy('setTimeout'); + const global = { + ...makeGlobal(), + setTimeout, MessageChannel: function() { return fakeChannel; - }, - setTimeout: setTimeout - }, - clearStack = jasmineUnderTest.getClearStack(global); - - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - clearStack(function() {}); - - expect(fakeChannel.port2.postMessage).toHaveBeenCalled(); - expect(setTimeout).not.toHaveBeenCalled(); - - clearStack(function() {}); - expect(fakeChannel.port2.postMessage.calls.count()).toEqual(9); - expect(setTimeout.calls.count()).toEqual(1); - - clearStack(function() {}); - expect(fakeChannel.port2.postMessage.calls.count()).toEqual(10); - expect(setTimeout.calls.count()).toEqual(1); - }); - - it('calls setTimeout when onmessage is called recursively', function() { - const fakeChannel = { - port1: {}, - port2: { - postMessage: function() { - fakeChannel.port1.onmessage(); - } } - }, - setTimeout = jasmine.createSpy('setTimeout'), - global = { - MessageChannel: function() { - return fakeChannel; - }, - setTimeout: setTimeout - }, - clearStack = jasmineUnderTest.getClearStack(global), - fn = jasmine.createSpy('second clearStack function'); + }; + const clearStack = jasmineUnderTest.getClearStack(global); - clearStack(function() { - clearStack(fn); + for (let i = 0; i < 9; i++) { + clearStack(function() {}); + } + + expect(fakeChannel.port2.postMessage).toHaveBeenCalled(); + expect(setTimeout).not.toHaveBeenCalled(); + + clearStack(function() {}); + expect(fakeChannel.port2.postMessage).toHaveBeenCalledTimes(9); + expect(setTimeout).toHaveBeenCalledTimes(1); + + clearStack(function() {}); + expect(fakeChannel.port2.postMessage).toHaveBeenCalledTimes(10); + expect(setTimeout).toHaveBeenCalledTimes(1); }); - expect(fn).not.toHaveBeenCalled(); - expect(setTimeout).toHaveBeenCalledWith(fn, 0); - }); + it('calls setTimeout when onmessage is called recursively', function() { + const setTimeout = jasmine.createSpy('setTimeout'); + const global = { + ...makeGlobal(), + setTimeout, + MessageChannel: fakeMessageChannel + }; + const clearStack = jasmineUnderTest.getClearStack(global); + const fn = jasmine.createSpy('second clearStack function'); - it('falls back to setTimeout', function() { - const setTimeout = jasmine - .createSpy('setTimeout') - .and.callFake(function(fn) { + clearStack(function() { + clearStack(fn); + }); + + expect(fn).not.toHaveBeenCalled(); + expect(setTimeout).toHaveBeenCalledWith(fn, 0); + }); + } + + function usesQueueMicrotaskWithSetTimeout(makeGlobal) { + it('uses queueMicrotask', function() { + const global = { + ...makeGlobal(), + queueMicrotask: function(fn) { fn(); - }), - global = { setTimeout: setTimeout }, - clearStack = jasmineUnderTest.getClearStack(global); - let called = false; + } + }; + const clearStack = jasmineUnderTest.getClearStack(global); + let called = false; - clearStack(function() { - called = true; + clearStack(function() { + called = true; + }); + + expect(called).toBe(true); }); - expect(called).toBe(true); - expect(setTimeout).toHaveBeenCalledWith(jasmine.any(Function), 0); - }); + it('uses setTimeout instead of queueMicrotask every 10 calls to make sure we release the CPU', function() { + const queueMicrotask = jasmine.createSpy('queueMicrotask'); + const setTimeout = jasmine.createSpy('setTimeout'); + const global = { + ...makeGlobal(), + queueMicrotask, + setTimeout + }; + const clearStack = jasmineUnderTest.getClearStack(global); + + for (let i = 0; i < 9; i++) { + clearStack(function() {}); + } + + expect(queueMicrotask).toHaveBeenCalled(); + expect(setTimeout).not.toHaveBeenCalled(); + + clearStack(function() {}); + expect(queueMicrotask).toHaveBeenCalledTimes(9); + expect(setTimeout).toHaveBeenCalledTimes(1); + + clearStack(function() {}); + expect(queueMicrotask).toHaveBeenCalledTimes(10); + expect(setTimeout).toHaveBeenCalledTimes(1); + }); + } + + function usesQueueMicrotaskWithoutSetTimeout(makeGlobal) { + it('uses queueMicrotask', function() { + const global = { + ...makeGlobal(), + queueMicrotask: function(fn) { + fn(); + } + }; + const clearStack = jasmineUnderTest.getClearStack(global); + let called = false; + + clearStack(function() { + called = true; + }); + + expect(called).toBe(true); + }); + + it('does not use setTimeout', function() { + const queueMicrotask = jasmine.createSpy('queueMicrotask'); + const setTimeout = jasmine.createSpy('setTimeout'); + const global = { + ...makeGlobal(), + queueMicrotask, + setTimeout + }; + const clearStack = jasmineUnderTest.getClearStack(global); + + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + clearStack(function() {}); + + expect(queueMicrotask).toHaveBeenCalledTimes(10); + expect(setTimeout).not.toHaveBeenCalled(); + }); + } + + function fakeMessageChannel() { + const channel = { + port1: {}, + port2: { + postMessage: function() { + channel.port1.onmessage(); + } + } + }; + return channel; + } }); diff --git a/spec/core/integration/EnvSpec.js b/spec/core/integration/EnvSpec.js index 01533038..92f27ad4 100644 --- a/spec/core/integration/EnvSpec.js +++ b/spec/core/integration/EnvSpec.js @@ -436,6 +436,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn, delay) { clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -473,6 +476,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn) { clearTimeout(fn); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -529,6 +535,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn, delay) { clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -545,8 +554,8 @@ describe('Env integration', function() { env.it('fails', function(specDone) { setTimeout(function() { specDone(); - setTimeout(function() { - setTimeout(function() { + queueMicrotask(function() { + queueMicrotask(function() { global.onerror('fail'); }); }); @@ -578,6 +587,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn, delay) { clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -638,6 +650,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn, delay) { clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -677,6 +692,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn) { clearTimeout(fn); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -1427,8 +1445,8 @@ describe('Env integration', function() { global: { setTimeout: globalSetTimeout, clearTimeout: clearTimeout, - setImmediate: function(cb) { - return setTimeout(cb, 0); + queueMicrotask: function(fn) { + queueMicrotask(fn); } } }); @@ -1501,8 +1519,8 @@ describe('Env integration', function() { clearTimeout: clearTimeout, setInterval: setInterval, clearInterval: clearInterval, - setImmediate: function(cb) { - return realSetTimeout(cb, 0); + queueMicrotask: function(fn) { + queueMicrotask(fn); } } }); @@ -2625,6 +2643,9 @@ describe('Env integration', function() { clearTimeout: function(fn, delay) { clearTimeout(fn, delay); }, + queueMicrotask: function(fn) { + queueMicrotask(fn); + }, onerror: function() {} }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -2680,6 +2701,9 @@ describe('Env integration', function() { clearTimeout: function(fn, delay) { clearTimeout(fn, delay); }, + queueMicrotask: function(fn) { + queueMicrotask(fn); + }, onerror: originalOnerror }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); @@ -2869,6 +2893,9 @@ describe('Env integration', function() { }, clearTimeout: function(fn, delay) { return clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); } }; spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); diff --git a/src/core/ClearStack.js b/src/core/ClearStack.js index 3d864244..ced9605a 100644 --- a/src/core/ClearStack.js +++ b/src/core/ClearStack.js @@ -1,8 +1,32 @@ getJasmineRequireObj().clearStack = function(j$) { const maxInlineCallCount = 10; - function messageChannelImpl(global, setTimeout) { - const channel = new global.MessageChannel(); + function browserQueueMicrotaskImpl(global) { + const { setTimeout, queueMicrotask } = global; + let currentCallCount = 0; + return function clearStack(fn) { + currentCallCount++; + + if (currentCallCount < maxInlineCallCount) { + queueMicrotask(fn); + } else { + currentCallCount = 0; + setTimeout(fn); + } + }; + } + + function nodeQueueMicrotaskImpl(global) { + const { queueMicrotask } = global; + + return function(fn) { + queueMicrotask(fn); + }; + } + + function messageChannelImpl(global) { + const { MessageChannel, setTimeout } = global; + const channel = new MessageChannel(); let head = {}; let tail = head; @@ -13,7 +37,7 @@ getJasmineRequireObj().clearStack = function(j$) { delete head.task; if (taskRunning) { - global.setTimeout(task, 0); + setTimeout(task, 0); } else { try { taskRunning = true; @@ -39,29 +63,31 @@ getJasmineRequireObj().clearStack = function(j$) { } function getClearStack(global) { - let currentCallCount = 0; - const realSetTimeout = global.setTimeout; - const setTimeoutImpl = function clearStack(fn) { - Function.prototype.apply.apply(realSetTimeout, [global, [fn, 0]]); - }; + const NODE_JS = + global.process && + global.process.versions && + typeof global.process.versions.node === 'string'; - if (j$.isFunction_(global.setImmediate)) { - const realSetImmediate = global.setImmediate; - return function(fn) { - currentCallCount++; + const SAFARI = + global.navigator && + /^((?!chrome|android).)*safari/i.test(global.navigator.userAgent); - if (currentCallCount < maxInlineCallCount) { - realSetImmediate(fn); - } else { - currentCallCount = 0; - - setTimeoutImpl(fn); - } - }; - } else if (!j$.util.isUndefined(global.MessageChannel)) { - return messageChannelImpl(global, setTimeoutImpl); + if (NODE_JS) { + // Unlike browsers, Node doesn't require us to do a periodic setTimeout + // so we avoid the overhead. + return nodeQueueMicrotaskImpl(global); + } else if ( + SAFARI || + j$.util.isUndefined(global.MessageChannel) /* tests */ + ) { + // queueMicrotask is dramatically faster than MessageChannel in Safari. + // Some of our own integration tests provide a mock queueMicrotask in all + // environments because it's simpler to mock than MessageChannel. + return browserQueueMicrotaskImpl(global); } else { - return setTimeoutImpl; + // MessageChannel is faster than queueMicrotask in supported browsers + // other than Safari. + return messageChannelImpl(global); } }