mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-11 17:32:41 +01:00
28c68eb4fd
Summary: Ref T12573. `JX.Leader` synchronizes the Aphlict connection across multiple windows. Currently, we only test to see if the leader window has been closed every 16 seconds. Instead, test every 1.5 seconds. Also, make windows keep trying to become the leader forever. This was removed previously (in D15806) but I think that change decreased robustness here. Test Plan: - Opened two windows to the "Realtime" tab in DarkConsole. - Saw one become the leader and one become a follower. - (Optionally, wait for 10 seconds here to test the "keep trying to become the leader" behavior.) - Closed the leader. - Saw the follower become the leader after ~1.5 seconds. Reviewers: chad Reviewed By: chad Maniphest Tasks: T12573 Differential Revision: https://secure.phabricator.com/D17703
341 lines
9.8 KiB
JavaScript
341 lines
9.8 KiB
JavaScript
/**
|
|
* @requires javelin-install
|
|
* @provides javelin-leader
|
|
* @javelin
|
|
*/
|
|
|
|
/**
|
|
* Synchronize multiple tabs over LocalStorage.
|
|
*
|
|
* This class elects one tab as the "Leader". It remains the leader until it
|
|
* is closed.
|
|
*
|
|
* Tabs can conditionally call a function if they are the leader using
|
|
* @{method:callIfLeader}. This will trigger leader election, and call the
|
|
* function if the current tab is elected. This can be used to keep one
|
|
* websocket open across a group of tabs, or play a sound only once in response
|
|
* to a server state change.
|
|
*
|
|
* Tabs can broadcast messages to other tabs using @{method:broadcast}. Each
|
|
* message has an optional ID. When a tab receives multiple copies of a message
|
|
* with the same ID, copies after the first copy are discarded. This can be
|
|
* used in conjunction with @{method:callIfLeader} to allow multiple event
|
|
* responders to trigger a reaction to an event (like a sound) and ensure that
|
|
* it is played only once (not once for each notification), and by only one
|
|
* tab (not once for each tab).
|
|
*
|
|
* Finally, tabs can register a callback which will run if they become the
|
|
* leading tab, by listening for `onBecomeLeader`.
|
|
*/
|
|
|
|
JX.install('Leader', {
|
|
|
|
events: ['onBecomeLeader', 'onReceiveBroadcast'],
|
|
|
|
statics: {
|
|
_leaseDuration: 1500,
|
|
|
|
_interval: null,
|
|
_timeout: null,
|
|
_broadcastKey: 'JX.Leader.broadcast',
|
|
_leaderKey: 'JX.Leader.id',
|
|
|
|
|
|
/**
|
|
* Tracks leadership state. Since leadership election is asynchronous,
|
|
* we can't expose this directly without inconsistent behavior.
|
|
*/
|
|
_isLeader: false,
|
|
|
|
|
|
/**
|
|
* Keeps track of message IDs we've seen, so we send each message only
|
|
* once.
|
|
*/
|
|
_seen: {},
|
|
|
|
|
|
/**
|
|
* Helps keep the list of seen message IDs from growing without bound.
|
|
*/
|
|
_seenList: [],
|
|
|
|
|
|
/**
|
|
* Elect a leader, triggering leadership callbacks if they are registered.
|
|
*/
|
|
start: function() {
|
|
var self = JX.Leader;
|
|
self.call(JX.bag);
|
|
},
|
|
|
|
/**
|
|
* Call a method if this tab is the leader.
|
|
*
|
|
* This is asynchronous because leadership election is asynchronous. If
|
|
* the current tab is not the leader after any election takes place, the
|
|
* callback will not be invoked.
|
|
*/
|
|
callIfLeader: function(callback) {
|
|
JX.Leader._callIf(callback, JX.bag);
|
|
},
|
|
|
|
|
|
/**
|
|
* Call a method after leader election.
|
|
*
|
|
* This is asynchronous because leadership election is asynchronous. The
|
|
* callback will be invoked after election takes place.
|
|
*
|
|
* This method is useful if you want to invoke a callback no matter what,
|
|
* but the callback behavior depends on whether this is the leader or
|
|
* not.
|
|
*/
|
|
call: function(callback) {
|
|
JX.Leader._callIf(callback, callback);
|
|
},
|
|
|
|
/**
|
|
* Elect a leader, then invoke either a leader callback or a follower
|
|
* callback.
|
|
*/
|
|
_callIf: function(leader_callback, follower_callback) {
|
|
var self = JX.Leader;
|
|
|
|
if (!window.localStorage) {
|
|
// If we don't have localStorage, pretend we're the only tab.
|
|
self._becomeLeader();
|
|
leader_callback();
|
|
return;
|
|
}
|
|
|
|
// If we don't have an ID for this tab yet, generate one and register
|
|
// event listeners.
|
|
if (!self._id) {
|
|
self._id = 1 + parseInt(Math.random() * 1000000000, 10);
|
|
JX.Stratcom.listen('pagehide', null, self._pagehide);
|
|
JX.Stratcom.listen('storage', null, self._storage);
|
|
}
|
|
|
|
// Read the current leadership lease.
|
|
var lease = self._read();
|
|
|
|
// Stagger these delays so that they are unlikely to race one another.
|
|
var expire_delay = 50;
|
|
var usurp_delay = 75;
|
|
|
|
// If the lease is good, we're all set.
|
|
var now = +new Date();
|
|
if (lease.until > now) {
|
|
if (lease.id === self._id) {
|
|
|
|
// If we haven't installed an update timer yet, do so now. This will
|
|
// renew our lease every 5 seconds, making sure we hold it until the
|
|
// tab is closed.
|
|
var interval = parseInt(self._leaseDuration / 3, 10);
|
|
|
|
if (!self._interval && lease.until > now + (interval * 2)) {
|
|
self._interval = window.setInterval(self._write, interval);
|
|
}
|
|
|
|
self._becomeLeader();
|
|
leader_callback();
|
|
} else {
|
|
|
|
// Set a callback to try to become the leader shortly after the
|
|
// current lease expires. This lets us quickly recover from cases
|
|
// where the leader goes missing.
|
|
|
|
// In particular, this can happen in Safari if you close windows or
|
|
// quit the browser instead of browsing away: the "pagehide" event
|
|
// does not fire when the leader is simply destroyed, so it does not
|
|
// evict itself from the throne of power.
|
|
if (!self._timeout) {
|
|
var usurp_at = (lease.until - now) + usurp_delay;
|
|
self._timeout = window.setTimeout(self._usurp, usurp_at);
|
|
}
|
|
|
|
follower_callback();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// If the lease isn't good, try to become the leader. We don't have
|
|
// proper locking primitives for this, but can do a relatively good
|
|
// job. The algorithm here is:
|
|
//
|
|
// - Write our ID, trying to acquire the lease.
|
|
// - Delay for much longer than a write "could possibly" take.
|
|
// - Read the key back.
|
|
// - If nothing else overwrote the key, we become the leader.
|
|
//
|
|
// This avoids a race where our reads and writes could otherwise
|
|
// interleave with another tab's reads and writes, electing both or
|
|
// neither as the leader.
|
|
//
|
|
// This approximately follows an algorithm attributed to Fischer in
|
|
// "A Fast Mutual Exclusion Algorithm" (Leslie Lamport, 1985). That
|
|
// paper also describes a faster (but more complex) algorithm, but
|
|
// it's not problematic to add a significant delay here because
|
|
// leader election is not especially performance-sensitive.
|
|
|
|
self._write();
|
|
|
|
window.setTimeout(
|
|
JX.bind(null, self._callIf, leader_callback, follower_callback),
|
|
expire_delay);
|
|
},
|
|
|
|
|
|
/**
|
|
* Send a message to all open tabs.
|
|
*
|
|
* Tabs can receive messages by listening to `onReceiveBroadcast`.
|
|
*
|
|
* @param string|null Message ID. If provided, subsequent messages with
|
|
* the same ID will be discarded.
|
|
* @param wild The message to send.
|
|
*/
|
|
broadcast: function(id, message) {
|
|
var self = JX.Leader;
|
|
if (id !== null) {
|
|
if (id in self._seen) {
|
|
return;
|
|
}
|
|
self._markSeen(id);
|
|
}
|
|
|
|
if (window.localStorage) {
|
|
var json = JX.JSON.stringify(
|
|
{
|
|
id: id,
|
|
message: message,
|
|
|
|
// LocalStorage only emits events if the value changes. Include
|
|
// a random component to make sure that broadcasts are never
|
|
// eaten. Although this is probably not often useful in a
|
|
// production system, it makes testing easier and more predictable.
|
|
uniq: parseInt(Math.random() * 1000000, 10)
|
|
});
|
|
window.localStorage.setItem(self._broadcastKey, json);
|
|
}
|
|
|
|
self._receiveBroadcast(message);
|
|
},
|
|
|
|
|
|
/**
|
|
* Write a lease which names us as the leader.
|
|
*/
|
|
_write: function() {
|
|
var self = JX.Leader;
|
|
|
|
var str = [self._id, ((+new Date()) + self._leaseDuration)].join(':');
|
|
window.localStorage.setItem(self._leaderKey, str);
|
|
},
|
|
|
|
|
|
/**
|
|
* Read the current lease.
|
|
*/
|
|
_read: function() {
|
|
var self = JX.Leader;
|
|
|
|
var leader = window.localStorage.getItem(self._leaderKey) || '0:0';
|
|
leader = leader.split(':');
|
|
|
|
return {
|
|
id: parseInt(leader[0], 10),
|
|
until: parseInt(leader[1], 10)
|
|
};
|
|
},
|
|
|
|
|
|
/**
|
|
* When the tab is closed, if we're the leader, release leadership.
|
|
*
|
|
* This will trigger a new election if there are other tabs open.
|
|
*/
|
|
_pagehide: function() {
|
|
var self = JX.Leader;
|
|
if (self._read().id === self._id) {
|
|
window.localStorage.removeItem(self._leaderKey);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* React to a storage update.
|
|
*/
|
|
_storage: function(e) {
|
|
var self = JX.Leader;
|
|
|
|
var key = e.getRawEvent().key;
|
|
var new_value = e.getRawEvent().newValue;
|
|
|
|
switch (key) {
|
|
case self._broadcastKey:
|
|
new_value = JX.JSON.parse(new_value);
|
|
if (new_value.id !== null) {
|
|
if (new_value.id in self._seen) {
|
|
return;
|
|
}
|
|
self._markSeen(new_value.id);
|
|
}
|
|
self._receiveBroadcast(new_value.message);
|
|
break;
|
|
case self._leaderKey:
|
|
// If the leader tab closed, elect a new leader.
|
|
if (new_value === null) {
|
|
self.callIfLeader(JX.bag);
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
|
|
_receiveBroadcast: function(message) {
|
|
var self = JX.Leader;
|
|
new JX.Leader().invoke('onReceiveBroadcast', message, self._isLeader);
|
|
},
|
|
|
|
_becomeLeader: function() {
|
|
var self = JX.Leader;
|
|
if (self._isLeader) {
|
|
return;
|
|
}
|
|
|
|
self._isLeader = true;
|
|
new JX.Leader().invoke('onBecomeLeader');
|
|
},
|
|
|
|
|
|
/**
|
|
* Try to usurp leadership position after a lease expiration.
|
|
*/
|
|
_usurp: function() {
|
|
var self = JX.Leader;
|
|
self._timeout = null;
|
|
self.call(JX.bag);
|
|
},
|
|
|
|
|
|
/**
|
|
* Mark a message as seen.
|
|
*
|
|
* We keep a fixed-sized list of recent messages, and let old ones fall
|
|
* off the end after a while.
|
|
*/
|
|
_markSeen: function(id) {
|
|
var self = JX.Leader;
|
|
|
|
self._seen[id] = true;
|
|
self._seenList.push(id);
|
|
while (self._seenList.length > 128) {
|
|
delete self._seen[self._seenList[0]];
|
|
self._seenList.splice(0, 1);
|
|
}
|
|
}
|
|
|
|
}
|
|
});
|