Page MenuHomePhabricator

D11235.id.diff
No OneTemporary

D11235.id.diff

diff --git a/resources/celerity/map.php b/resources/celerity/map.php
--- a/resources/celerity/map.php
+++ b/resources/celerity/map.php
@@ -166,7 +166,7 @@
'rsrc/externals/javelin/core/__tests__/install.js' => 'c432ee85',
'rsrc/externals/javelin/core/__tests__/stratcom.js' => 'da194d4b',
'rsrc/externals/javelin/core/__tests__/util.js' => 'd3b157a9',
- 'rsrc/externals/javelin/core/init.js' => 'b88ab49e',
+ 'rsrc/externals/javelin/core/init.js' => '76e1fd61',
'rsrc/externals/javelin/core/init_node.js' => 'd7dde471',
'rsrc/externals/javelin/core/install.js' => '1ffb3a9c',
'rsrc/externals/javelin/core/util.js' => '90e3fde9',
@@ -193,6 +193,7 @@
'rsrc/externals/javelin/lib/DOM.js' => 'c4569c05',
'rsrc/externals/javelin/lib/History.js' => 'c60f4327',
'rsrc/externals/javelin/lib/JSON.js' => '69adf288',
+ 'rsrc/externals/javelin/lib/Leader.js' => '9f8874bb',
'rsrc/externals/javelin/lib/Mask.js' => '8a41885b',
'rsrc/externals/javelin/lib/Request.js' => '97258e55',
'rsrc/externals/javelin/lib/Resource.js' => '0f81f8df',
@@ -405,7 +406,7 @@
'rsrc/js/application/policy/behavior-policy-rule-editor.js' => 'fe9a552f',
'rsrc/js/application/ponder/behavior-votebox.js' => '4e9b766b',
'rsrc/js/application/projects/behavior-boards-dropdown.js' => '0ec56e1d',
- 'rsrc/js/application/projects/behavior-project-boards.js' => '8c2ab1e0',
+ 'rsrc/js/application/projects/behavior-project-boards.js' => '87cb6b51',
'rsrc/js/application/projects/behavior-project-create.js' => '065227cc',
'rsrc/js/application/projects/behavior-reorder-columns.js' => 'e1d25dfb',
'rsrc/js/application/releeph/releeph-preview-branch.js' => 'b2b4fbaf',
@@ -631,7 +632,7 @@
'javelin-behavior-policy-control' => 'f3fef818',
'javelin-behavior-policy-rule-editor' => 'fe9a552f',
'javelin-behavior-ponder-votebox' => '4e9b766b',
- 'javelin-behavior-project-boards' => '8c2ab1e0',
+ 'javelin-behavior-project-boards' => '87cb6b51',
'javelin-behavior-project-create' => '065227cc',
'javelin-behavior-refresh-csrf' => '7814b593',
'javelin-behavior-releeph-preview-branch' => 'b2b4fbaf',
@@ -659,7 +660,8 @@
'javelin-history' => 'c60f4327',
'javelin-install' => '1ffb3a9c',
'javelin-json' => '69adf288',
- 'javelin-magical-init' => 'b88ab49e',
+ 'javelin-leader' => '9f8874bb',
+ 'javelin-magical-init' => '76e1fd61',
'javelin-mask' => '8a41885b',
'javelin-reactor' => '77b1cf6f',
'javelin-reactor-dom' => 'b6d401d6',
@@ -1350,6 +1352,15 @@
'85ea0626' => array(
'javelin-install',
),
+ '87cb6b51' => array(
+ 'javelin-behavior',
+ 'javelin-dom',
+ 'javelin-util',
+ 'javelin-vector',
+ 'javelin-stratcom',
+ 'javelin-workflow',
+ 'phabricator-draggable-list',
+ ),
'88236f00' => array(
'javelin-behavior',
'phabricator-keyboard-shortcut',
@@ -1381,15 +1392,6 @@
'javelin-request',
'javelin-typeahead-source',
),
- '8c2ab1e0' => array(
- 'javelin-behavior',
- 'javelin-dom',
- 'javelin-util',
- 'javelin-vector',
- 'javelin-stratcom',
- 'javelin-workflow',
- 'phabricator-draggable-list',
- ),
'8c49f386' => array(
'javelin-install',
'javelin-util',
@@ -1468,6 +1470,9 @@
'javelin-request',
'phabricator-shaped-request',
),
+ '9f8874bb' => array(
+ 'javelin-install',
+ ),
'a155550f' => array(
'javelin-install',
'javelin-dom',
diff --git a/webroot/rsrc/externals/javelin/core/init.js b/webroot/rsrc/externals/javelin/core/init.js
--- a/webroot/rsrc/externals/javelin/core/init.js
+++ b/webroot/rsrc/externals/javelin/core/init.js
@@ -184,6 +184,9 @@
'hashchange'
];
+ if (window.localStorage) {
+ window_events.push('storage');
+ }
for (ii = 0; ii < window_events.length; ++ii) {
JX.enableDispatch(window, window_events[ii]);
diff --git a/webroot/rsrc/externals/javelin/lib/Leader.js b/webroot/rsrc/externals/javelin/lib/Leader.js
new file mode 100644
--- /dev/null
+++ b/webroot/rsrc/externals/javelin/lib/Leader.js
@@ -0,0 +1,308 @@
+/**
+ * @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: {
+ _interval: 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.callIfLeader(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) {
+
+ if (!window.localStorage) {
+ // If we don't have localStorage, pretend we're the only tab.
+ self._becomeLeader();
+ leader_callback();
+ return;
+ }
+
+ var self = JX.Leader;
+
+ // 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();
+
+ // 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.
+ if (!self._interval && lease.until > now + 10000) {
+ self._interval = window.setInterval(self._write, 5000);
+ }
+
+ self._becomeLeader();
+ leader_callback();
+ } else {
+ 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),
+ 50);
+ },
+
+
+ /**
+ * 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()) + 16000)].join(':');
+ window.localStorage.setItem(self._leaderKey, str);
+ },
+
+
+ /**
+ * Read the current lease.
+ */
+ _read: function() {
+ var self = JX.Leader;
+
+ 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');
+ },
+
+ /**
+ * 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);
+ }
+ }
+
+ }
+});
+

File Metadata

Mime Type
text/plain
Expires
Mar 19 2025, 3:33 AM (5 w, 15 h ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7601860
Default Alt Text
D11235.id.diff (13 KB)

Event Timeline