Files
odoo_source/addons/bus/static/tests/bus_tests.js
T
Aaron Bohy 31fcd5a0f1 [FIX] bus: prevent longpoll requests storm
Before this commit, it might happen that, in some situations,
with several tabs opened, the CrossTabBus called the longpolling
route repeatedly, thus slowing down the server, and freezing the
webclient.

The issue was tricky to reproduce. It was a race-condition that
could occur when several tabs performed simultanous calls to
addChannel, while being unloaded or becoming mastertab in the
meantime (e.g. when opening/closing/refreshing several tabs
simultaneously).

This issue has been introduced by [1] which by mistake (probably)
made each tab calling itself the localStorage to update the list
of channels when it was notified that the list of channels in
the localStorage just changed. So if several tabs had a slightly
different list of channels at a given moment (e.g. at startup),
it might happen that they in turn, undo what another tab just
put in the localStorage, and thus produced an infinite loop of
localStorage writes and longpolling request aborts/calls.

The issue could be reproduced with the OCA module [2], which
performs several addChannel at webclient startup.

This commit restores this part of the code as it was initially
written in [3].

Closes #69067

opw~2502799
maybe opw~2451865 as well

[1] https://github.com/odoo/odoo/commit/6448420
[2] https://odoo-community.org/shop/product/web-notify-2670#attr=10773
[3] https://github.com/odoo/odoo/commit/38581f67236377daa767ca2216529a26b8708b00#diff-f6eccad21ae3543606ab8f97b8b097d015412caeaee2bf8cc928eb3ccabac9f5R149

closes odoo/odoo#69777

X-original-commit: a52aa41d04330efb81090409ec7fbcbbedaca317
Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
2021-04-23 15:38:17 +00:00

392 lines
14 KiB
JavaScript

odoo.define('web.bus_tests', function (require) {
"use strict";
var BusService = require('bus.BusService');
var CrossTabBus = require('bus.CrossTab');
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', async function (assert) {
assert.expect(6);
var pollPromise = testUtils.makeTestPromise();
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, {
data: {},
services: {
bus_service: BusService,
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route, args) {
if (route === '/longpolling/poll') {
assert.step(route + ' - ' + args.channels.join(','));
pollPromise = testUtils.makeTestPromise();
pollPromise.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromise);
return pollPromise;
}
return this._super.apply(this, arguments);
}
});
var widget = new Widget(parent);
await widget.appendTo($('#qunit-fixture'));
widget.call('bus_service', 'onNotification', this, function (notifications) {
assert.step('notification - ' + notifications.toString());
});
widget.call('bus_service', 'addChannel', 'lambda');
pollPromise.resolve([{
id: 1,
channel: 'lambda',
message: 'beta',
}]);
await testUtils.nextTick();
pollPromise.resolve([{
id: 2,
channel: 'lambda',
message: 'epsilon',
}]);
await testUtils.nextTick();
assert.verifySteps([
'/longpolling/poll - lambda',
'notification - lambda,beta',
'/longpolling/poll - lambda',
'notification - lambda,epsilon',
'/longpolling/poll - lambda',
]);
parent.destroy();
});
QUnit.test('provide notification ID of 0 by default', async function (assert) {
// This test is important in order to ensure that we provide the correct
// sentinel value 0 when we are not aware of the last notification ID
// that we have received. We cannot provide an ID of -1, otherwise it
// may likely be handled incorrectly (before this test was written,
// it was providing -1 to the server, which in return sent every stored
// notifications related to this user).
assert.expect(3);
// Simulate no ID of last notification in the local storage
testUtils.mock.patch(LocalStorageServiceMock, {
getItem: function (key) {
if (key === 'last_ts') {
return 0;
}
return this._super.apply(this, arguments);
},
});
var pollPromise = testUtils.makeTestPromise();
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, {
data: {},
services: {
bus_service: BusService,
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route, args) {
if (route === '/longpolling/poll') {
assert.step(route);
assert.strictEqual(args.last, 0,
"provided last notification ID should be 0");
pollPromise = testUtils.makeTestPromise();
pollPromise.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromise);
return pollPromise;
}
return this._super.apply(this, arguments);
}
});
var widget = new Widget(parent);
await widget.appendTo($('#qunit-fixture'));
// trigger longpolling poll RPC
widget.call('bus_service', 'addChannel', 'lambda');
assert.verifySteps(['/longpolling/poll']);
testUtils.mock.unpatch(LocalStorageServiceMock);
parent.destroy();
});
QUnit.test('cross tab bus share message from a channel', async function (assert) {
assert.expect(5);
// master
var pollPromiseMaster = testUtils.makeTestPromise();
var parentMaster = new Widget();
await testUtils.mock.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(','));
pollPromiseMaster = testUtils.makeTestPromise();
pollPromiseMaster.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromiseMaster);
return pollPromiseMaster;
}
return this._super.apply(this, arguments);
}
});
var master = new Widget(parentMaster);
await master.appendTo($('#qunit-fixture'));
master.call('bus_service', 'onNotification', master, function (notifications) {
assert.step('master - notification - ' + notifications.toString());
});
master.call('bus_service', 'addChannel', 'lambda');
// slave
await testUtils.nextTick();
var parentSlave = new Widget();
await testUtils.mock.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);
await slave.appendTo($('#qunit-fixture'));
slave.call('bus_service', 'onNotification', slave, function (notifications) {
assert.step('slave - notification - ' + notifications.toString());
});
slave.call('bus_service', 'addChannel', 'lambda');
pollPromiseMaster.resolve([{
id: 1,
channel: 'lambda',
message: 'beta',
}]);
await testUtils.nextTick();
assert.verifySteps([
'master - /longpolling/poll - lambda',
'master - notification - lambda,beta',
'slave - notification - lambda,beta',
'master - /longpolling/poll - lambda',
]);
parentMaster.destroy();
parentSlave.destroy();
});
QUnit.test('cross tab bus elect new master on master unload', async function (assert) {
assert.expect(8);
// master
var pollPromiseMaster = testUtils.makeTestPromise();
var parentMaster = new Widget();
await testUtils.mock.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(','));
pollPromiseMaster = testUtils.makeTestPromise();
pollPromiseMaster.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromiseMaster);
return pollPromiseMaster;
}
return this._super.apply(this, arguments);
}
});
var master = new Widget(parentMaster);
await master.appendTo($('#qunit-fixture'));
master.call('bus_service', 'onNotification', master, function (notifications) {
assert.step('master - notification - ' + notifications.toString());
});
master.call('bus_service', 'addChannel', 'lambda');
// slave
await testUtils.nextTick();
var parentSlave = new Widget();
var pollPromiseSlave = testUtils.makeTestPromise();
await testUtils.mock.addMockEnvironment(parentSlave, {
data: {},
services: {
bus_service: BusService,
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route, args) {
if (route === '/longpolling/poll') {
assert.step('slave - ' + route + ' - ' + args.channels.join(','));
pollPromiseSlave = testUtils.makeTestPromise();
pollPromiseSlave.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromiseSlave);
return pollPromiseSlave;
}
return this._super.apply(this, arguments);
}
});
var slave = new Widget(parentSlave);
await slave.appendTo($('#qunit-fixture'));
slave.call('bus_service', 'onNotification', slave, function (notifications) {
assert.step('slave - notification - ' + notifications.toString());
});
slave.call('bus_service', 'addChannel', 'lambda');
pollPromiseMaster.resolve([{
id: 1,
channel: 'lambda',
message: 'beta',
}]);
await testUtils.nextTick();
// simulate unloading master
master.call('bus_service', '_onUnload');
pollPromiseSlave.resolve([{
id: 2,
channel: 'lambda',
message: 'gamma',
}]);
await testUtils.nextTick();
assert.verifySteps([
'master - /longpolling/poll - lambda',
'master - notification - lambda,beta',
'slave - notification - lambda,beta',
'master - /longpolling/poll - lambda',
'slave - /longpolling/poll - lambda',
'slave - notification - lambda,gamma',
'slave - /longpolling/poll - lambda',
]);
parentMaster.destroy();
parentSlave.destroy();
});
QUnit.test('two tabs calling addChannel simultaneously', async function (assert) {
assert.expect(5);
let id = 1;
testUtils.patch(CrossTabBus, {
init: function () {
this._super.apply(this, arguments);
this.__tabId__ = id++;
},
addChannel: function (channel) {
assert.step('Tab ' + this.__tabId__ + ': addChannel ' + channel);
this._super.apply(this, arguments);
},
deleteChannel: function (channel) {
assert.step('Tab ' + this.__tabId__ + ': deleteChannel ' + channel);
this._super.apply(this, arguments);
},
});
let pollPromise;
const parentTab1 = new Widget();
await testUtils.addMockEnvironment(parentTab1, {
data: {},
services: {
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route) {
if (route === '/longpolling/poll') {
pollPromise = testUtils.makeTestPromise();
pollPromise.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromise);
return pollPromise;
}
return this._super.apply(this, arguments);
}
});
const parentTab2 = new Widget();
await testUtils.addMockEnvironment(parentTab2, {
data: {},
services: {
local_storage: LocalStorageServiceMock,
},
mockRPC: function (route) {
if (route === '/longpolling/poll') {
pollPromise = testUtils.makeTestPromise();
pollPromise.abort = (function () {
this.reject({message: "XmlHttpRequestError abort"}, $.Event());
}).bind(pollPromise);
return pollPromise;
}
return this._super.apply(this, arguments);
}
});
const tab1 = new CrossTabBus(parentTab1);
const tab2 = new CrossTabBus(parentTab2);
tab1.addChannel("alpha");
tab2.addChannel("alpha");
tab1.addChannel("beta");
tab2.addChannel("beta");
assert.verifySteps([
"Tab 1: addChannel alpha",
"Tab 2: addChannel alpha",
"Tab 1: addChannel beta",
"Tab 2: addChannel beta",
]);
testUtils.unpatch(CrossTabBus);
parentTab1.destroy();
parentTab2.destroy();
});
});
});