From 6448420c5dd160470e465dee7729d19d8d5e7bab Mon Sep 17 00:00:00 2001 From: Christophe Matthieu Date: Wed, 8 Aug 2018 18:54:43 +0200 Subject: [PATCH] [IMP] bus: re-factoring of bus.bus (Longpolling and CrossTab) The purpose of this change is to make the code clearer and testable. In this change, the 'tab_manager' static object was merged with the bus cross tab. Cleaning was done to clearly define private and public functions as well as handlers. The methods are documented and the constants are now defined on the class. The bus use the service behavior with 'trigger_up'. 'bus.CrossTab' who extend 'bus.Longpolling' are instantiated by the bus service. The class is always instantiated with a parent, or root in the case of the website (im_livechat), to use the ajax and localstorage services. So the behavior, perhaps logger or redefined by the parents. --- addons/bus/static/src/js/bus.js | 485 ------------------ addons/bus/static/src/js/crosstab_bus.js | 355 +++++++++++++ addons/bus/static/src/js/longpolling_bus.js | 255 +++++++++ .../bus/static/src/js/services/bus_service.js | 44 +- addons/bus/static/tests/bus_tests.js | 163 ++++++ addons/bus/views/bus.xml | 21 +- .../calendar/static/src/js/base_calendar.js | 2 +- .../im_livechat/static/src/js/im_livechat.js | 9 +- .../views/im_livechat_channel_templates.xml | 11 +- .../im_support/static/src/js/mail_manager.js | 13 +- .../im_support/static/src/js/support_bus.js | 24 +- .../static/tests/helpers/test_utils.js | 9 + .../im_support/static/tests/systray_tests.js | 4 +- addons/im_support/views/assets.xml | 8 +- .../services/mail_document_thread_manager.js | 17 +- .../static/src/js/services/mail_manager.js | 11 +- .../js/services/mail_notification_manager.js | 2 +- addons/mail/static/tests/chatter_tests.js | 7 +- .../static/tests/discuss_moderation_tests.js | 14 +- addons/mail/static/tests/discuss_tests.js | 18 +- .../tests/document_thread_window_tests.js | 10 +- .../mail/static/tests/helpers/test_utils.js | 24 +- .../systray/systray_messaging_menu_tests.js | 4 +- .../mail/static/tests/thread_window_tests.js | 1 - .../src/js/core/abstract_storage_service.js | 14 +- .../web/static/src/js/core/local_storage.js | 41 +- addons/web/static/src/js/core/ram_storage.js | 13 +- .../web/static/src/js/core/service_mixins.js | 3 +- .../web/static/src/js/core/session_storage.js | 41 +- .../web_editor/static/src/js/root_widget.js | 14 +- .../views/website_livechat.xml | 6 +- 31 files changed, 1020 insertions(+), 623 deletions(-) delete mode 100644 addons/bus/static/src/js/bus.js create mode 100644 addons/bus/static/src/js/crosstab_bus.js create mode 100644 addons/bus/static/src/js/longpolling_bus.js create mode 100644 addons/bus/static/tests/bus_tests.js diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js deleted file mode 100644 index bf5bb5277be..00000000000 --- a/addons/bus/static/src/js/bus.js +++ /dev/null @@ -1,485 +0,0 @@ -odoo.define('bus.bus', function (require) { -"use strict"; - -var local_storage = require('web.local_storage'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var bus = {}; -var PARTNERS_PRESENCE_CHECK_PERIOD = 30000; // don't check presence more than once every 30s - -var TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds -var MASTER_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 second - -bus.ERROR_DELAY = 10000; - -bus.Bus = Widget.extend({ - /** - * @override - * @param {Object} params - * @param {Object} params.session the session to use to perform the poll RPC - * @param {string} params.pollRoute the longpolling route to use - */ - init: function (params) { - var self = this; - this._super(); - this.pollRoute = params.pollRoute; - this.session = params.session; - this.options = {}; - this.activated = false; - this.bus_id = _.uniqueId('bus'); - this.channels = []; - this.last = 0; - this.stop = false; - this.is_master = true; - - // bus presence - this.last_presence = new Date().getTime(); - this.last_partners_presence_check = this.last_presence; - this.set("window_focus", true); - this.on("change:window_focus", this, function () { - if (this.get("window_focus")) { - this.trigger('window_focus', this.is_master); - } - }); - $(window).on("focus." + this.bus_id, _.bind(this.focus_change, this, true)); - $(window).on("blur." + this.bus_id, _.bind(this.focus_change, this, false)); - $(window).on("unload." + this.bus_id, _.bind(this.focus_change, this, false)); - _.each('click,keydown,keyup'.split(','), function (evtype) { - $(window).on(evtype + "." + self.bus_id, function () { - self.last_presence = new Date().getTime(); - }); - }); - }, - destroy: function () { - var self = this; - this.stop_polling(); - $(window).off("focus." + this.bus_id); - $(window).off("blur." + this.bus_id); - $(window).off("unload." + this.bus_id); - _.each('click,keydown,keyup'.split(','), function (evtype) { - $(window).off(evtype + "." + self.bus_id); - }); - }, - start_polling: function () { - if (!this.activated) { - this.poll(); - this.stop = false; - } - }, - stop_polling: function () { - this.activated = false; - this.stop = true; - this.channels = []; - if (this._pollRpc) { - this._pollRpc.abort(); - } - }, - poll: function () { - var self = this; - self.activated = true; - var now = new Date().getTime(); - var options = _.extend({}, this.options, { - bus_inactivity: now - this.get_last_presence(), - }); - if (this.last_partners_presence_check + PARTNERS_PRESENCE_CHECK_PERIOD > now) { - options = _.omit(options, 'bus_presence_partner_ids'); - } else { - this.last_partners_presence_check = now; - } - var data = {channels: self.channels, last: self.last, options: options}; - // The backend has a maximum cycle time of 50 seconds so give +10 seconds - this._pollRpc = this.session.rpc(this.pollRoute, data, {shadow : true, timeout: 60000}); - this._pollRpc.then(function (result) { - self._pollRpc = false; - self.on_notification(result); - if (!self.stop && self.activated) { - self.poll(); - } - }, function (error, event) { - self._pollRpc = false; - // no error popup if request is interrupted or fails for any reason - event.preventDefault(); - if (error && error.message === "XmlHttpRequestError abort") { - if (!self.stop && self.activated) { - self.poll(); - } - } else { - // random delay to avoid massive longpolling - setTimeout(_.bind(self.poll, self), bus.ERROR_DELAY + (Math.floor((Math.random()*20)+1)*1000)); - } - }); - }, - on_notification: function (notifications) { - var self = this; - var notifs = _.map(notifications, function (notif) { - if (notif.id > self.last) { - self.last = notif.id; - } - return [notif.channel, notif.message]; - }); - this.trigger("notification", notifs); - }, - add_channel: function (channel) { - if (this.channels.indexOf(channel) === -1) { - this.channels.push(channel); - if (this._pollRpc) { - this._pollRpc.abort(); - } - } - }, - delete_channel: function (channel) { - var index = this.channels.indexOf(channel); - if (index !== -1) { - this.channels.splice(index, 1); - if (this._pollRpc) { - this._pollRpc.abort(); - } - } - }, - // bus presence : window focus/unfocus - focus_change: function (focus) { - this.set("window_focus", focus); - }, - is_odoo_focused: function () { - return this.get("window_focus"); - }, - get_last_presence: function () { - return this.last_presence; - }, - update_option: function (key, value) { - this.options[key] = value; - }, - delete_option: function (key) { - if (_.contains(_.keys(this.options), key)) { - delete this.options[key]; - } - }, -}); - - - -/** - * CrossTabBus Widget - * - * Manage the communication before browser tab to allow only one tab polling for the others (performance improvement) - * When a tab is opened, and the start_polling method is called, the tab is signaling through the localStorage to the - * others. When a tab is closed, it signals its removing. If he was the master tab (the polling one), he choose another - * one in the list of open tabs. This one start polling for the other. When a notification is recieved from the poll, it - * is signaling through the localStorage too. - * - * localStorage used keys are: - * - * - bus.channels : shared public channel list to listen during the poll - * - bus.options : shared options - * - bus.notification : the received notifications from the last poll - * - bus.tab_list : list of opened tab ids - * - bus.tab_master : generated id of the master tab - */ -bus.CrossTabBus = bus.Bus.extend({ - init: function () { - this._super.apply(this, arguments); - - // used to prefix localStorage keys - this.sanitizedOrigin = this.session.origin.replace(/:\/{0,2}/g, '_'); - - this.is_master = false; - this.is_registered = false; - if (parseInt(this._getItem('last_ts', 0)) + 50000 < new Date().getTime()) { - this._setItem('last', -1); - } - - on("storage", this.on_storage.bind(this)); - }, - start_polling: function () { - var self = this; - if (!this.is_registered) { - this.is_registered = true; - tab_manager.register_tab(function () { - self.is_master = true; - self.start_polling(); - }, function () { - self.is_master = false; - self.stop_polling(); - }, function () { - // Write last_presence in local storage if it has been updated since last heartbeat - var hb_period = this.is_master ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD; - if (self.last_presence + hb_period > new Date().getTime()) { - self._setItem('last_presence', self.last_presence); - } - }, this._generateKey.bind(this)); - if (this.is_master) { - this._setItem('channels', this.channels); - this._setItem('options', this.options); - } else { - this.channels = this._getItem('channels', this.channels); - this.options = this._getItem('options', this.options); - } - return; // start_polling will be called again on tab registration - } - - if (this.is_master) { - this._super.apply(this, arguments); - } - }, - on_notification: function (notifications) { - if (this.is_master) { // broadcast to other tabs - var last = this._getItem('last', -1); - var max_id = Math.max(last, 0); - var new_notifications = _.filter(notifications, function (notif) { - max_id = Math.max(max_id, notif.id); - return notif.id < 0 || notif.id > last; - }); - this.last = max_id; - if (new_notifications.length) { - this._setItem('last', max_id); - this._setItem('last_ts', new Date().getTime()); - this._setItem('notification', new_notifications); - this._super(new_notifications); - } - } else { - this._super.apply(this, arguments); - } - }, - on_storage: function (e) { - // use the value of event to not read from - // localStorage (avoid race condition) - var value = e.newValue; - // notifications changed - if (e.key === this._generateKey('notification')) { - var notifs = JSON.parse(value); - this.on_notification(notifs); - } - // update channels - if (e.key === this._generateKey('channels')) { - this.channels = JSON.parse(value); - } - // update options - if (e.key === this._generateKey('options')) { - this.options = JSON.parse(value); - } - // update focus - if (e.key === this._generateKey('focus')) { - this.set('window_focus', JSON.parse(value)); - } - }, - add_channel: function () { - this._super.apply(this, arguments); - this._setItem('channels', this.channels); - }, - delete_channel: function () { - this._super.apply(this, arguments); - this._setItem('channels', this.channels); - }, - get_last_presence: function () { - return this._getItem('last_presence') || new Date().getTime(); - }, - update_option: function () { - this._super.apply(this, arguments); - this._setItem('options', this.options); - }, - delete_option: function () { - this._super.apply(this, arguments); - this._setItem('options', this.options); - }, - focus_change: function (focus) { - this._super.apply(this, arguments); - this._setItem('focus', focus); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Generates localStorage keys prefixed by bus. (the name of this addon), - * and the sanitized origin, to prevent keys from conflicting when several - * bus instances (polling different origins) co-exist. - * - * @private - * @param {string} key - * @returns key prefixed with the origin - */ - _generateKey: function (key) { - return 'bus.' + this.sanitizedOrigin + '.' + key; - }, - /** - * @private - * @param {string} key - * @param {*} defaultValue - */ - _getItem: function (key, defaultValue) { - return getItem(this._generateKey(key), defaultValue); - }, - /** - * @private - * @param {string} key - * @param {*} value - */ - _setItem: function (key, value) { - setItem(this._generateKey(key), value); - }, -}); - - -//utility functions -function on(type, listener) { - if (window.addEventListener) { - window.addEventListener(type, listener); - } else { //IE8 - window.attachEvent('on' + type, listener); - } -} - -function getItem(key, defaultValue) { - var val = local_storage.getItem(key); - return val ? JSON.parse(val) : defaultValue; -} - -function setItem(key, value) { - local_storage.setItem(key, JSON.stringify(value)); -} - - -var tab_manager = { - isMaster: false, - id: new Date().getTime() + ':' + (Math.random() * 1000000000 | 0), - - register_tab: function (is_master_callback, is_no_longer_master, on_heartbeat_callback, generateKey) { - this.heartbeatKey = generateKey('heartbeat'); - this.masterKey = generateKey('master'); - this.peersKey = generateKey('peers'); - - this.is_master_callback = is_master_callback; - this.is_no_longer_master = is_no_longer_master || function () {}; - this.on_heartbeat_callback = on_heartbeat_callback || function () {}; - - var peers = getItem(tab_manager.peersKey, {}); - peers[tab_manager.id] = new Date().getTime(); - setItem(tab_manager.peersKey, peers); - - on('unload', function () { - // unload peer - var peers = getItem(tab_manager.peersKey, {}); - delete peers[tab_manager.id]; - setItem(tab_manager.peersKey, peers); - - // unload master - if (tab_manager.isMaster) { - local_storage.removeItem(tab_manager.masterKey); - } - }); - - if (!local_storage.getItem(tab_manager.masterKey)) { - tab_manager.start_election(); - } - - on('storage', function (e) { - if (!e) { e = window.event;} - if (e.key !== tab_manager.masterKey) { - return; - } - - if (e.newValue === null) { //master was unloaded - tab_manager.start_election(); - } - - }); - tab_manager.heartbeat(); - }, - heartbeat: function () { - var current = new Date().getTime(); - var heartbeatValue = local_storage.getItem(tab_manager.heartbeatKey) || 0; - var peers = getItem(tab_manager.peersKey, {}); - - if ((parseInt(heartbeatValue) + 5000) < current) { - // Heartbeat is out of date. Electing new master - tab_manager.start_election(); - } - if (tab_manager.isMaster) { - //walk through all peers and kill old - var cleanedPeers = {}; - for (var peerName in peers) { - if (peers[peerName] + 15000 > current) { - cleanedPeers[peerName] = peers[peerName]; - } - } - if (!tab_manager.is_last_heartbeat_mine()) { - // someone else is also master... - // it should not happen, except in some race condition situation. - tab_manager.isMaster = false; - tab_manager.last_heartbeat = 0; - peers[tab_manager.id] = current; - setItem(tab_manager.peersKey, peers); - tab_manager.is_no_longer_master(); - } else { - tab_manager.last_heartbeat = current; - local_storage.setItem(tab_manager.heartbeatKey, current); - setItem(tab_manager.peersKey, cleanedPeers); - } - } else { - //update own heartbeat - peers[tab_manager.id] = current; - setItem(tab_manager.peersKey, peers); - } - this.on_heartbeat_callback(); - - setTimeout(function () { - tab_manager.heartbeat(); - }, tab_manager.isMaster ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD); - }, - is_last_heartbeat_mine: function () { - var heartbeatValue = local_storage.getItem(tab_manager.heartbeatKey) || 0; - return (parseInt(heartbeatValue) === tab_manager.last_heartbeat); - }, - start_election: function () { - if (tab_manager.isMaster) { - return; - } - //check who's next - var peers = getItem(tab_manager.peersKey, {}); - var now = new Date().getTime(); - var newMaster; - - for (var peerName in peers) { - //check for dead peers - if (peers[peerName] + 15000 < now) { - continue; - } - - newMaster = peerName; - break; - } - if (newMaster === tab_manager.id) { - //we're next in queue. Electing as master - setItem(tab_manager.masterKey, tab_manager.id); - tab_manager.last_heartbeat = new Date().getTime(); - setItem(tab_manager.heartbeatKey, tab_manager.last_heartbeat); - tab_manager.isMaster = true; - tab_manager.is_master_callback(); - - //removing master peer from queue - delete peers[newMaster]; - setItem(tab_manager.peersKey, peers); - } - }, -}; - - -// bus singleton, depending of the browser : -// if supporting LocalStorage, there will be only one tab polling -var params = { - pollRoute: '/longpolling/poll', - session: session, -}; -if (typeof Storage !== "undefined") { - bus.bus = new bus.CrossTabBus(params); -} else { - bus.bus = new bus.Bus(params); -} - -return bus; - - -}); - diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js new file mode 100644 index 00000000000..c0402dcac10 --- /dev/null +++ b/addons/bus/static/src/js/crosstab_bus.js @@ -0,0 +1,355 @@ +odoo.define('bus.CrossTab', function (require) { +"use strict"; + +var Longpolling = require('bus.Longpolling'); +var session = require('web.session'); + + +/** + * CrossTab + * + * This is an extension of the longpolling bus with browser cross-tab synchronization. + * It uses a Master/Slaves with Leader Election architecture: + * - a single tab handles longpolling. + * - tabs are synchronized by means of the local storage. + * + * localStorage used keys are: + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.channels : shared public channel list to listen during the poll + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.options : shared options + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.notification : the received notifications from the last poll + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_list : list of opened tab ids + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_master : generated id of the master tab + * + * trigger: + * - window_focus : when the window is focused + * - notification : when a notification is receive from the long polling + * - become_master : when this tab became the master + * - no_longer_master : when this tab is not longer the master (the user swith tab) + */ +var CrossTabBus = Longpolling.extend({ + // constants + TAB_HEARTBEAT_PERIOD: 10000, // 10 seconds + MASTER_TAB_HEARTBEAT_PERIOD: 1500, // 1.5 seconds + HEARTBEAT_OUT_OF_DATE_PERIOD: 5000, // 5 seconds + HEARTBEAT_KILL_OLD_PERIOD: 15000, // 15 seconds + LOCAL_STORAGE_PREFIX: 'bus', + + // properties + _isMasterTab: false, + _isRegistered: false, + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + var now = new Date().getTime(); + var session = this.getSession(); + // used to prefix localStorage keys + this._sanitizedOrigin = session.origin.replace(/:\/{0,2}/g, '_'); + // prevents collisions between different tabs and in tests + this._id = _.uniqueId(this.LOCAL_STORAGE_PREFIX) + ':' + now; + if (this._callLocalStorage('getItem', 'last_ts', 0) + 50000 < now) { + this._callLocalStorage('removeItem', 'last'); + } + this._lastNotificationID = this._callLocalStorage('getItem', 'last', -1); + this.call('local_storage', 'onStorage', this, this._onStorage); + }, + destroy: function () { + this._super(); + clearTimeout(this._heartbeatTimeout); + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Share the bus channels with the others tab by the local storage + * + * @override + */ + addChannel: function () { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'channels', this._channels); + }, + /** + * Share the bus channels with the others tab by the local storage + * + * @override + */ + deleteChannel: function () { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'channels', this._channels); + }, + /** + * Tells whether this bus is related to the master tab. + * + * @returns {boolean} + */ + isMasterTab: function () { + return this._isMasterTab; + }, + /** + * Use the local storage to share the long polling from the master tab. + * + * @override + */ + startPolling: function () { + if (this._isActive === null) { + this._heartbeat = this._heartbeat.bind(this); + } + if (!this._isRegistered) { + this._isRegistered = true; + + var peers = this._callLocalStorage('getItem', 'peers', {}); + peers[this._id] = new Date().getTime(); + this._callLocalStorage('setItem', 'peers', peers); + + $(window).on('unload.' + this._id, this._onUnload.bind(this)); + + if (!this._callLocalStorage('getItem', 'peers')) { + this._startElection(); + } + + this._heartbeat(); + + if (this._isMasterTab) { + this._callLocalStorage('setItem', 'channels', this._channels); + this._callLocalStorage('setItem', 'options', this._options); + } else { + this._channels = this._callLocalStorage('getItem', 'channels', this._channels); + this._options = this._callLocalStorage('getItem', 'options', this._options); + } + return; // startPolling will be called again on tab registration + } + + if (this._isMasterTab) { + this._super.apply(this, arguments); + } + }, + /** + * Share the option with the local storage + * + * @override + */ + updateOption: function () { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'options', this._options); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * Call local_storage service + * + * @private + * @param {string} method (getItem, setItem, removeItem, on) + * @param {string} key + * @param {any} param + * @returns service information + */ + _callLocalStorage: function (method, key, param) { + return this.call('local_storage', method, this._generateKey(key), param); + }, + /** + * Generates localStorage keys prefixed by bus. (LOCAL_STORAGE_PREFIX = the name + * of this addon), and the sanitized origin, to prevent keys from + * conflicting when several bus instances (polling different origins) + * co-exist. + * + * @private + * @param {string} key + * @returns key prefixed with the origin + */ + _generateKey: function (key) { + return this.LOCAL_STORAGE_PREFIX + '.' + this._sanitizedOrigin + '.' + key; + }, + /** + * @override + * @returns {integer} number of milliseconds since 1 January 1970 00:00:00 + */ + _getLastPresence: function () { + return this._callLocalStorage('getItem', 'lastPresence') || this._super(); + }, + /** + * Check all the time (according to the constants) if the tab is the master tab and + * check if it is active. Use the local storage for this checks. + * + * @private + * @see _startElection method + */ + _heartbeat: function () { + var now = new Date().getTime(); + var heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0)); + var peers = this._callLocalStorage('getItem', 'peers', {}); + + if ((heartbeatValue + this.HEARTBEAT_OUT_OF_DATE_PERIOD) < now) { + // Heartbeat is out of date. Electing new master + this._startElection(); + heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0)); + } + + if (this._isMasterTab) { + //walk through all peers and kill old + var cleanedPeers = {}; + for (var peerName in peers) { + if (peers[peerName] + this.HEARTBEAT_KILL_OLD_PERIOD > now) { + cleanedPeers[peerName] = peers[peerName]; + } + } + + if (heartbeatValue !== this.lastHeartbeat) { + // someone else is also master... + // it should not happen, except in some race condition situation. + this._isMasterTab = false; + this.lastHeartbeat = 0; + peers[this._id] = now; + this._callLocalStorage('setItem', 'peers', peers); + this.stopPolling(); + this.trigger('no_longer_master'); + } else { + this.lastHeartbeat = now; + this._callLocalStorage('setItem', 'heartbeat', now); + this._callLocalStorage('setItem', 'peers', cleanedPeers); + } + } else { + //update own heartbeat + peers[this._id] = now; + this._callLocalStorage('setItem', 'peers', peers); + } + + // Write lastPresence in local storage if it has been updated since last heartbeat + var hbPeriod = this._isMasterTab ? this.MASTER_TAB_HEARTBEAT_PERIOD : this.TAB_HEARTBEAT_PERIOD; + if (this._lastPresenceTime + hbPeriod > now) { + this._callLocalStorage('setItem', 'lastPresence', this._lastPresenceTime); + } + + this._heartbeatTimeout = setTimeout(this._heartbeat, hbPeriod); + }, + /** + * Check with the local storage if the current tab is the master tab. + * If this tab became the master, trigger 'become_master' event + * + * @private + */ + _startElection: function () { + if (this._isMasterTab) { + return; + } + //check who's next + var now = new Date().getTime(); + var peers = this._callLocalStorage('getItem', 'peers', {}); + var heartbeatKillOld = now - this.HEARTBEAT_KILL_OLD_PERIOD; + var newMaster; + for (var peerName in peers) { + //check for dead peers + if (peers[peerName] < heartbeatKillOld) { + continue; + } + newMaster = peerName; + break; + } + + if (newMaster === this._id) { + //we're next in queue. Electing as master + this.lastHeartbeat = now; + this._callLocalStorage('setItem', 'heartbeat', this.lastHeartbeat); + this._isMasterTab = true; + this.startPolling(); + this.trigger('become_master'); + + //removing master peer from queue + delete peers[newMaster]; + this._callLocalStorage('setItem', 'peers', peers); + } + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * @override + */ + _onFocusChange: function (params) { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'focus', params.focus); + }, + /** + * If it's the master tab, the notifications ares broadcasted to other tabs by the + * local storage. + * + * @override + */ + _onPoll: function (notifications) { + var notifs = this._super(notifications); + if (this._isMasterTab && notifs.length) { + this._callLocalStorage('setItem', 'last', this._lastNotificationID); + this._callLocalStorage('setItem', 'last_ts', new Date().getTime()); + this._callLocalStorage('setItem', 'notification', notifs); + } + }, + /** + * Handler when the local storage is updated + * + * @private + * @param {OdooEvent} event + * @param {string} event.key + * @param {string} event.newValue + */ + _onStorage: function (e) { + var value = JSON.parse(e.newValue); + var key = e.key; + + if (this._isRegistered && key === this._generateKey('peers') && !value) { + //master was unloaded + this._startElection(); + } + + // last notification id changed + if (key === this._generateKey('last')) { + this._lastNotificationID = value; + } + // notifications changed + + if (key === this._generateKey('notification')) { + if (!this._isMasterTab) { + this.trigger("notification", value); + } + return; + } + // update channels + if (key === this._generateKey('channels')) { + var channels = value; + _.each(_.difference(this._channels, channels), this.deleteChannel.bind(this)); + _.each(_.difference(channels, this._channels), this.addChannel.bind(this)); + } + // update options + if (key === this._generateKey('options')) { + this._options = value; + } + // update focus + if (key === this._generateKey('focus')) { + this._isOdooFocused = value; + this.trigger('window_focus', this._isOdooFocused); + } + }, + /** + * Handler when unload the window + * + * @private + */ + _onUnload: function () { + // unload peer + var peers = this._callLocalStorage('getItem', 'peers', {}); + delete peers[this._id]; + this._callLocalStorage('setItem', 'peers', peers); + + // unload master + if (this._isMasterTab) { + this._callLocalStorage('removeItem', 'peers'); + } + }, +}); + +return CrossTabBus; + +}); + diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js new file mode 100644 index 00000000000..4764e2e52b0 --- /dev/null +++ b/addons/bus/static/src/js/longpolling_bus.js @@ -0,0 +1,255 @@ +odoo.define('bus.Longpolling', function (require) { +"use strict"; + +var Bus = require('web.Bus'); +var ServicesMixin = require('web.ServicesMixin'); + + +/** + * Event Longpolling bus used to bind events on the server long polling return + * + * trigger: + * - window_focus : when the window focus change (true for focused, false for blur) + * - notification : when a notification is receive from the long polling + * + * @class Longpolling + */ +var LongpollingBus = Bus.extend(ServicesMixin, { + // constants + PARTNERS_PRESENCE_CHECK_PERIOD: 30000, // don't check presence more than once every 30s + ERROR_RETRY_DELAY: 10000, // 10 seconds + POLL_ROUTE: '/longpolling/poll', + + // properties + _isActive: null, + _lastNotificationID: 0, + _isOdooFocused: true, + _pollRetryTimeout: null, + + /** + * @override + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this._id = _.uniqueId('bus'); + this._options = {}; + this._channels = []; + + // bus presence + this._lastPresenceTime = new Date().getTime(); + this._lastPartnersPresenceCheck = this._lastPresenceTime; + $(window).on("focus." + this._id, this._onFocusChange.bind(this, {focus: true})); + $(window).on("blur." + this._id, this._onFocusChange.bind(this, {focus: false})); + $(window).on("unload." + this._id, this._onFocusChange.bind(this, {focus: false})); + + $(window).on("click." + this._id, this._onPresence.bind(this)); + $(window).on("keydown." + this._id, this._onPresence.bind(this)); + $(window).on("keyup." + this._id, this._onPresence.bind(this)); + }, + /** + * @override + */ + destroy: function () { + this.stopPolling(); + $(window).off("focus." + this._id); + $(window).off("blur." + this._id); + $(window).off("unload." + this._id); + $(window).off("click." + this._id); + $(window).off("keydown." + this._id); + $(window).off("keyup." + this._id); + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Register a new channel to listen on the longpoll (ignore if already + * listening on this channel). + * Aborts a pending longpoll, in order to re-start another longpoll, so + * that we can immediately get notifications on newly registered channel. + * + * @param {string} channel + */ + addChannel: function (channel) { + if (this._channels.indexOf(channel) === -1) { + this._channels.push(channel); + if (this._pollRpc) { + this._pollRpc.abort(); + } else { + this.startPolling(); + } + } + }, + /** + * Unregister a channel from listening on the longpoll. + * + * Aborts a pending longpoll, in order to re-start another longpoll, so + * that we immediately remove ourselves from listening on notifications + * on this channel. + * + * @param {string} channel + */ + deleteChannel: function (channel) { + var index = this._channels.indexOf(channel); + if (index !== -1) { + this._channels.splice(index, 1); + if (this._pollRpc) { + this._pollRpc.abort(); + } + } + }, + /** + * Tell whether odoo is focused or not + * + * @returns {boolean} + */ + isOdooFocused: function () { + return this._isOdooFocused; + }, + /** + * Start a long polling, i.e. it continually opens a long poll + * connection as long as it is not stopped (@see `stopPolling`) + */ + startPolling: function () { + if (this._isActive === null) { + this._poll = this._poll.bind(this); + } + if (!this._isActive) { + this._isActive = true; + this._poll(); + } + }, + /** + * Stops any started long polling + * + * Aborts a pending longpoll so that we immediately remove ourselves + * from listening on notifications on this channel. + */ + stopPolling: function () { + this._isActive = false; + this._channels = []; + clearTimeout(this._pollRetryTimeout); + if (this._pollRpc) { + this._pollRpc.abort(); + } + }, + /** + * Add or update an option on the longpoll bus. + * Stored options are sent to the server whenever a poll is started. + * + * @param {string} key + * @param {any} value + */ + updateOption: function (key, value) { + this._options[key] = value; + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * returns the last recorded presence + * + * @private + * @returns {integer} number of milliseconds since 1 January 1970 00:00:00 + */ + _getLastPresence: function () { + return this._lastPresenceTime; + }, + /** + * Continually start a poll: + * + * A poll is a connection that is kept open for a relatively long period + * (up to 1 minute). Local bus data are sent to the server each time a poll + * is initiated, and the server may return some "real-time" notifications + * about registered channels. + * + * A poll ends on timeout, on abort, on receiving some notifications, or on + * receiving an error. Another poll usually starts afterward, except if the + * poll is aborted or stopped (@see stopPolling). + * + * @private + */ + _poll: function () { + var self = this; + if (!this._isActive) { + return; + } + var now = new Date().getTime(); + var options = _.extend({}, this._options, { + bus_inactivity: now - this._getLastPresence(), + }); + if (this._lastPartnersPresenceCheck + this.PARTNERS_PRESENCE_CHECK_PERIOD > now) { + options = _.omit(options, 'bus_presence_partner_ids'); + } else { + this._lastPartnersPresenceCheck = now; + } + var data = {channels: this._channels, last: this._lastNotificationID, options: options}; + // The backend has a maximum cycle time of 50 seconds so give +10 seconds + this._pollRpc = this._rpc({route: this.POLL_ROUTE, params: data}, {shadow : true, timeout: 60000}); + this._pollRpc.then(function (result) { + self._pollRpc = false; + self._onPoll(result); + self._poll(); + }, function (error, ev) { + self._pollRpc = false; + // no error popup if request is interrupted or fails for any reason + ev.preventDefault(); + if (error && error.message === "XmlHttpRequestError abort") { + self._poll(); + } else { + // random delay to avoid massive longpolling + self._pollRetryTimeout = setTimeout(self._poll, self.ERROR_RETRY_DELAY + (Math.floor((Math.random()*20)+1)*1000)); + } + }); + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * Handler when the focus of the window change. + * Trigger the 'window_focus' event. + * + * @private + * @param {Object} params + * @param {Boolean} params.focus + */ + _onFocusChange: function (params) { + this._isOdooFocused = params.focus; + if (params.focus) { + this._lastPresenceTime = new Date().getTime(); + this.trigger('window_focus', this._isOdooFocused); + } + }, + /** + * Handler when the long polling receive the new notifications + * Update the last notification id received. + * Triggered the 'notification' event with a list [channel, message] from notifications. + * + * @private + * @param {Object[]} notifications, Input notifications have an id, channel, message + * @returns {Array[]} Output arrays have notification's channel and message + */ + _onPoll: function (notifications) { + var self = this; + var notifs = _.map(notifications, function (notif) { + if (notif.id > self._lastNotificationID) { + self._lastNotificationID = notif.id; + } + return [notif.channel, notif.message]; + }); + this.trigger("notification", notifs); + return notifs; + }, + /** + * Handler when they are an activity on the window (click, keydown, keyup) + * Update the last presence date. + * + * @private + */ + _onPresence: function () { + this._lastPresenceTime = new Date().getTime(); + }, +}); + +return LongpollingBus; + +}); diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js index e0bf8b42636..8e367b7f93a 100644 --- a/addons/bus/static/src/js/services/bus_service.js +++ b/addons/bus/static/src/js/services/bus_service.js @@ -1,34 +1,27 @@ odoo.define('bus.BusService', function (require) { "use strict"; -var bus = require('bus.bus').bus; - -var AbstractService = require('web.AbstractService'); +var CrossTab = require('bus.CrossTab'); var core = require('web.core'); +var ServicesMixin = require('web.ServicesMixin'); + +var BusService = CrossTab.extend(ServicesMixin, { + dependencies : ['local_storage'], + + // properties + _audio: null, -var BusService = AbstractService.extend({ /** - * @override + * This method is necessary in order for this Class to be used to instantiate services + * + * @abstract */ - start: function () { - this._super.apply(this, arguments); - this.bus = bus; - this._audio = null; - }, + start: function () {}, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * Get the bus - * - * @return {web.Bus} the longpoll bus - */ - getBus: function () { - return this.bus; - }, - /** * Send a notification, and notify once per browser's tab * @@ -37,16 +30,25 @@ var BusService = AbstractService.extend({ */ sendNotification: function (title, content) { if (window.Notification && Notification.permission === "granted") { - if (this.bus.is_master) { + if (this.isMasterTab()) { this._sendNativeNotification(title, content); } } else { this.do_notify(title, content); - if (this.bus.is_master) { + if (this.isMasterTab()) { this._beep(); } } }, + /** + * Register listeners on notifications received on this bus service + * + * @param {Object} receiver + * @param {function} func + */ + onNotification: function () { + this.on.apply(this, ["notification"].concat(Array.prototype.slice.call(arguments))); + }, //-------------------------------------------------------------------------- // Private diff --git a/addons/bus/static/tests/bus_tests.js b/addons/bus/static/tests/bus_tests.js new file mode 100644 index 00000000000..33255f8d8de --- /dev/null +++ b/addons/bus/static/tests/bus_tests.js @@ -0,0 +1,163 @@ +odoo.define('web.bus_tests', function (require) { +"use strict"; + +var BusService = require('bus.BusService'); +var AbstractStorageService = require('web.AbstractStorageService'); +var RamStorage = require('web.RamStorage'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + + +var LocalStorageServiceMock; + +BusService = BusService.extend({ + TAB_HEARTBEAT_PERIOD: 10, + MASTER_TAB_HEARTBEAT_PERIOD: 1, +}); + + +QUnit.module('Bus', { + beforeEach: function () { + LocalStorageServiceMock = AbstractStorageService.extend({storage: new RamStorage()}); + }, +}, function () { + QUnit.test('notifications received from the longpolling channel', function (assert) { + assert.expect(6); + + var pollDeferred = $.Deferred(); + + var parent = new Widget(); + testUtils.addMockEnvironment(parent, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step([route, args.channels.join(',')]); + + pollDeferred = $.Deferred(); + pollDeferred.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollDeferred); + return pollDeferred; + } + return this._super.apply(this, arguments); + } + }); + + var widget = new Widget(parent); + widget.appendTo($('#qunit-fixture')); + + widget.call('bus_service', 'onNotification', this, function (notifications) { + assert.step(['notification', notifications]); + }); + widget.call('bus_service', 'addChannel', 'lambda'); + + pollDeferred.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + pollDeferred.resolve([{ + id: 2, + channel: 'lambda', + message: 'epsilon', + }]); + + assert.verifySteps([ + ["/longpolling/poll", "lambda"], + ["notification", [["lambda","beta"]]], + ["/longpolling/poll", "lambda"], + ["notification", [["lambda","epsilon"]]], + ["/longpolling/poll", "lambda"] + ]); + + parent.destroy(); + }); + + QUnit.test('cross tab bus share message from a channel', function (assert) { + var done = assert.async(); + assert.expect(5); + + // master + + var pollDeferredMaster = $.Deferred(); + + var parentMaster = new Widget(); + testUtils.addMockEnvironment(parentMaster, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step(['master', route, args.channels.join(',')]); + + pollDeferredMaster = $.Deferred(); + pollDeferredMaster.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollDeferredMaster); + return pollDeferredMaster; + } + return this._super.apply(this, arguments); + } + }); + + var master = new Widget(parentMaster); + master.appendTo($('#qunit-fixture')); + + master.call('bus_service', 'onNotification', master, function (notifications) { + assert.step(['master', 'notification', notifications]); + }); + master.call('bus_service', 'addChannel', 'lambda'); + + // slave + + setTimeout(function () { + var parentSlave = new Widget(); + testUtils.addMockEnvironment(parentSlave, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + throw new Error("Can not use the longpolling of the slave client"); + } + return this._super.apply(this, arguments); + } + }); + + var slave = new Widget(parentSlave); + slave.appendTo($('#qunit-fixture')); + + slave.call('bus_service', 'onNotification', slave, function (notifications) { + assert.step(['slave', 'notification', notifications]); + }); + slave.call('bus_service', 'addChannel', 'lambda'); + + pollDeferredMaster.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + + assert.verifySteps([ + ["master", "/longpolling/poll", "lambda"], + ["master", "notification", [["lambda", "beta"]]], + ["slave", "notification", [["lambda", "beta"]]], + ["master", "/longpolling/poll", "lambda"], + ]); + + parentMaster.destroy(); + parentSlave.destroy(); + + done(); + + }, 3); + }); +});}); diff --git a/addons/bus/views/bus.xml b/addons/bus/views/bus.xml index 914393eefb6..4d8f7bc36de 100644 --- a/addons/bus/views/bus.xml +++ b/addons/bus/views/bus.xml @@ -1,11 +1,16 @@ - - - + + +