From d0af2220dc68a23dfa279bcb2471b5e5460600fe Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 13:20:41 -0800 Subject: [PATCH 01/24] Virtual baton. --- examples/libraries/virtualBaton.18.js | 197 ++++++++++++++++++++++++++ examples/tests/testBaton.js | 27 ++++ 2 files changed, 224 insertions(+) create mode 100644 examples/libraries/virtualBaton.18.js create mode 100644 examples/tests/testBaton.js diff --git a/examples/libraries/virtualBaton.18.js b/examples/libraries/virtualBaton.18.js new file mode 100644 index 0000000000..c44bac0760 --- /dev/null +++ b/examples/libraries/virtualBaton.18.js @@ -0,0 +1,197 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true */ +/*global Entities, Script, MyAvatar, Messages, AvatarList, print */ +// +// Created by Howard Stearns +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +// Allows cooperating scripts to pass a "virtual baton" between them, +// which is useful when part of a script should only be executed by +// the one participant that is holding this particular baton. +// +// A virtual baton is simply any string agreed upon by the scripts +// that use it. Only one script at a time can hold the baton, and it +// holds it until that script releases it, or the other scripts +// determine that the holding script is not responding. The script +// automatically determines who among claimants has the baton, if anyone, +// and holds an "election" if necessary. +// +// See entityScript/tribble.js as an example, and the functions +// virtualBaton(), claim(), release(). +// + +// Answers a new virtualBaton for the given parameters, of which 'key' +// is required. +virtualBaton = function virtualBaton(options) { + var key = options.key, + channel = "io.highfidelity.virtualBaton:" + key, + exports = options.exports || {}, + timeout = options.timeout || 5, // seconds + claimCallback, + releaseCallback, + // paxos proposer state + nPromises = 0, + nQuorum, + mostRecentInterested, + bestPromise = {number: 0}, + // paxos acceptor state + bestProposal = {number: 0}, + accepted = null; + if (!key) { + throw new Error("A VirtualBaton must specify a key."); + } + function debug() { + print.apply(null, [].map.call(arguments, JSON.stringify)); + } + function send(operation, data) { + debug('baton: send', operation, data); + var message = JSON.stringify({op: operation, data: data}); + Messages.sendMessage(channel, message); + } + function doRelease() { + var callback = releaseCallback, oldAccepted = accepted; + accepted = releaseCallback = undefined; + debug('baton: doRelease', key, callback); + if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. + Messages.messageReceived.disconnect(messageHandler); + Messages.unsubscribe(channel); // Messages currently allow publishing without subscription. + send('release', oldAccepted); // This order is less crufty. + callback(key); // Pass key so that clients may use the same handler for different batons. + } + + // Internally, this uses the Paxos algorith to hold elections. + // Alternatively, we could have the message server pick and maintain a winner, but it would + // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. + // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer + // (such as the server). + function acceptedId() { return accepted && accepted.winner; } + // Paxos makes several tests of one "proposal number" versus another, assuming + // that better proposals from the same proposer have a higher number, + // and different proposers use a different set of numbers. We achieve that + // by dividing the "number" into two parts, and integer and a proposerId, + // which keeps the combined number unique and yet still strictly ordered. + function betterNumber(number, best) { + debug('baton: betterNumber', number, best); + return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); + } + function propose(claim) { + debug('baton: propose', claim); + if (!claimCallback) { return; } // We're not participating. + nPromises = 0; + nQuorum = Math.floor(AvatarList.getAvatarIdentifiers.length / 2) + 1; + bestPromise.proposerId = MyAvatar.sessionUUID; + bestPromise.number++; + bestPromise.winner = claim; + send('prepare!', bestPromise); + // Fixme: set a watchdog that is cancelled when we send accept!, and which propose(claim) when it goes off. + } + + function messageHandler(messageChannel, messageString, senderID) { + if (messageChannel !== channel) { return; } + var message = JSON.parse(messageString), data = message.data; + debug('baton: received from', senderID, message.op, data); + switch (message.op) { + case 'prepare!': + // Optimization: Don't waste time with low future proposals. + // Does not remove the need for betterNumber() to consider proposerId, because + // participants might not receive this prepare! message before their next proposal. + //FIXME bestPromise.number = Math.max(bestPromise.number, data.number); + + if (betterNumber(data, bestProposal)) { + var response = accepted || data; + if (!response.winner && claimCallback) { + // Optimization: Let the proposer know we're interested in the job if the proposer doesn't + // know who else to pick. Avoids needing to start multiple simultaneous proposals. + response.interested = MyAvatar.sessionUUID; + } + bestProposal = data; + send('promise', response); + } // FIXME nack? + break; + case 'promise': + if (data.proposerId !== MyAvatar.sessionUUID) { return; } // Only the proposer needs to do anything. + mostRecentInterested = mostRecentInterested || data.interested; + if (betterNumber(data, bestPromise)) { + bestPromise = data; + } + if (++nPromises >= nQuorum) { + if (!bestPromise.winner) { // we get to pick + bestPromise.winner = claimCallback ? MyAvatar.sessionUUID : mostRecentInterested; + } + send('accept!', bestPromise); + } + break; + case 'accept!': + if (!betterNumber(bestProposal, data)) { + accepted = data; + send('accepted', accepted); + } + // FIXME: start interval (with a little random offset?) that claims if winner is ever not in AvatarList and we still claimCallback + break; + case 'accepted': + accepted = data; + if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not been the proposer. + if (claimCallback) { + var callback = claimCallback; + claimCallback = undefined; + callback(key); + } else { // We won, but are no longer interested. + propose(); // Propose that someone else take the job. + } + } + break; + case 'release': + if (!betterNumber(accepted, data)) { // Unless our data is fresher... + accepted.winner = undefined; // ... allow next proposer to have his way. + } + break; + default: + print("Unrecognized virtualBaton message:", message); + } + } + // Registers an intent to hold the baton: + // Calls onElection(key) once, if you are elected by the scripts + // to be the unique holder of the baton, which may be never. + // Calls onRelease(key) once, if you release the baton held by you, + // whether this is by you calling release(), or by loosing + // an election when you become disconnected. + // You may claim again at any time after the start of onRelease + // being called. Otherwise, you will not participate in further elections. + exports.claim = function claim(onElection, onRelease) { + debug('baton: claim'); + if (claimCallback) { + print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); + return; + } + if (releaseCallback) { + print("Ignoring attempt to claim virtualBaton " + key + ", which is somehow incorrect released, and that should not happen."); + return; + } + claimCallback = onElection; + releaseCallback = onRelease; + Messages.messageReceived.connect(messageHandler); + Messages.subscribe(channel); + propose(MyAvatar.sessionUUID); + }; + + // Release the baton you hold, or just log that you are not holding it. + exports.release = function release(optionalReplacementOnRelease) { + debug('baton: release'); + if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. + releaseCallback = optionalReplacementOnRelease; + } + if (acceptedId() !== MyAvatar.sessionUUID) { + print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); + return; + } + doRelease(); + if (!claimCallback) { // No claim set in release callback. + propose(); // We are the distinguished proposer, but we'll pick anyone else interested. + } + }; + + return exports; +}; diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js new file mode 100644 index 0000000000..3a44ecd92a --- /dev/null +++ b/examples/tests/testBaton.js @@ -0,0 +1,27 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true*/ +var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; + +Script.include("../libraries/virtualBaton.18.js"); +var TICKER_INTERVAL = 1000; // ms +var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); +var ticker, countDown; + +// Tick every TICKER_INTERVAL. +function gotBaton(key) { + print("gotBaton", key); + countDown = 20; + ticker = Script.startInterval(function () { + print("tick"); + }, 1000); +} +// If we've lost the baton (e.g., to network problems), stop ticking +// but ask for the baton back (waiting indefinitely to get it). +function lostBaton(key) { + print("lostBaton", key); + Script.clearInterval(ticker); + baton.claim(gotBaton, lostBaton); +} +baton.claim(gotBaton, lostBaton); + + From cdff3323fb8c55704bd11567dc45e63b2b2acd59 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 13:25:42 -0800 Subject: [PATCH 02/24] typo --- examples/tests/testBaton.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 3a44ecd92a..08c8691d5c 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -11,7 +11,7 @@ var ticker, countDown; function gotBaton(key) { print("gotBaton", key); countDown = 20; - ticker = Script.startInterval(function () { + ticker = Script.setInterval(function () { print("tick"); }, 1000); } From 38443af43750dbbc2d5a5bcacb22903c69f6b827 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 13:36:40 -0800 Subject: [PATCH 03/24] typo --- examples/libraries/{virtualBaton.18.js => virtualBaton.19.js} | 2 +- examples/tests/testBaton.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename examples/libraries/{virtualBaton.18.js => virtualBaton.19.js} (99%) diff --git a/examples/libraries/virtualBaton.18.js b/examples/libraries/virtualBaton.19.js similarity index 99% rename from examples/libraries/virtualBaton.18.js rename to examples/libraries/virtualBaton.19.js index c44bac0760..653681dda6 100644 --- a/examples/libraries/virtualBaton.18.js +++ b/examples/libraries/virtualBaton.19.js @@ -81,7 +81,7 @@ virtualBaton = function virtualBaton(options) { debug('baton: propose', claim); if (!claimCallback) { return; } // We're not participating. nPromises = 0; - nQuorum = Math.floor(AvatarList.getAvatarIdentifiers.length / 2) + 1; + nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; bestPromise.proposerId = MyAvatar.sessionUUID; bestPromise.number++; bestPromise.winner = claim; diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 08c8691d5c..02ac2e5232 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -2,7 +2,7 @@ /*jslint nomen: true, plusplus: true, vars: true*/ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; -Script.include("../libraries/virtualBaton.18.js"); +Script.include("../libraries/virtualBaton.19.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); var ticker, countDown; From 3d01b3ec2b2f90b649951c95367a9298a0cc2edd Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 14:15:03 -0800 Subject: [PATCH 04/24] watchdog --- .../{virtualBaton.19.js => virtualBaton.20.js} | 13 ++++++++++--- examples/tests/testBaton.js | 14 +++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) rename examples/libraries/{virtualBaton.19.js => virtualBaton.20.js} (94%) diff --git a/examples/libraries/virtualBaton.19.js b/examples/libraries/virtualBaton.20.js similarity index 94% rename from examples/libraries/virtualBaton.19.js rename to examples/libraries/virtualBaton.20.js index 653681dda6..76811d2f48 100644 --- a/examples/libraries/virtualBaton.19.js +++ b/examples/libraries/virtualBaton.20.js @@ -29,7 +29,6 @@ virtualBaton = function virtualBaton(options) { var key = options.key, channel = "io.highfidelity.virtualBaton:" + key, exports = options.exports || {}, - timeout = options.timeout || 5, // seconds claimCallback, releaseCallback, // paxos proposer state @@ -37,6 +36,8 @@ virtualBaton = function virtualBaton(options) { nQuorum, mostRecentInterested, bestPromise = {number: 0}, + electionTimeout = options.electionTimeout || 1000, // ms. If no winner in this time, hold a new election + electionWatchdog, // paxos acceptor state bestProposal = {number: 0}, accepted = null; @@ -79,6 +80,7 @@ virtualBaton = function virtualBaton(options) { } function propose(claim) { debug('baton: propose', claim); + if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } if (!claimCallback) { return; } // We're not participating. nPromises = 0; nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; @@ -86,9 +88,10 @@ virtualBaton = function virtualBaton(options) { bestPromise.number++; bestPromise.winner = claim; send('prepare!', bestPromise); - // Fixme: set a watchdog that is cancelled when we send accept!, and which propose(claim) when it goes off. + electionWatchdog = Script.setTimeout(function () { + propose(claim); + }, electionTimeout); } - function messageHandler(messageChannel, messageString, senderID) { if (messageChannel !== channel) { return; } var message = JSON.parse(messageString), data = message.data; @@ -134,6 +137,10 @@ virtualBaton = function virtualBaton(options) { case 'accepted': accepted = data; if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not been the proposer. + if (electionWatchdog) { + Script.clearTimeout(electionWatchdog); + electionWatchdog = null; + } if (claimCallback) { var callback = claimCallback; claimCallback = undefined; diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 02ac2e5232..5ba5d99565 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -1,8 +1,18 @@ "use strict"; /*jslint nomen: true, plusplus: true, vars: true*/ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; +// +// Created by Howard Stearns +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +// test libraries/virtualBaton.js +// All participants should run the test script. -Script.include("../libraries/virtualBaton.19.js"); + +Script.include("../libraries/virtualBaton.20.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); var ticker, countDown; @@ -23,5 +33,3 @@ function lostBaton(key) { baton.claim(gotBaton, lostBaton); } baton.claim(gotBaton, lostBaton); - - From fbcacbe14ae0f58aedc2c72e9bd9e024a43fa5a6 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 14:31:32 -0800 Subject: [PATCH 05/24] simpler betterNumber test. --- examples/libraries/{virtualBaton.20.js => virtualBaton.21.js} | 3 ++- examples/tests/testBaton.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename examples/libraries/{virtualBaton.20.js => virtualBaton.21.js} (98%) diff --git a/examples/libraries/virtualBaton.20.js b/examples/libraries/virtualBaton.21.js similarity index 98% rename from examples/libraries/virtualBaton.20.js rename to examples/libraries/virtualBaton.21.js index 76811d2f48..7cd9281555 100644 --- a/examples/libraries/virtualBaton.20.js +++ b/examples/libraries/virtualBaton.21.js @@ -76,7 +76,8 @@ virtualBaton = function virtualBaton(options) { // which keeps the combined number unique and yet still strictly ordered. function betterNumber(number, best) { debug('baton: betterNumber', number, best); - return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); + //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); + return (number.number || 0) > best.number; } function propose(claim) { debug('baton: propose', claim); diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 5ba5d99565..90f9219d09 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -12,7 +12,7 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // All participants should run the test script. -Script.include("../libraries/virtualBaton.20.js"); +Script.include("../libraries/virtualBaton.21.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); var ticker, countDown; From 03244fbeb58937381e4e05869a378540ec938781 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 15:37:22 -0800 Subject: [PATCH 06/24] fix promise-sending. --- ...{virtualBaton.21.js => virtualBaton.25.js} | 27 ++++++++++--------- examples/tests/testBaton.js | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) rename examples/libraries/{virtualBaton.21.js => virtualBaton.25.js} (91%) diff --git a/examples/libraries/virtualBaton.21.js b/examples/libraries/virtualBaton.25.js similarity index 91% rename from examples/libraries/virtualBaton.21.js rename to examples/libraries/virtualBaton.25.js index 7cd9281555..90b04b8dda 100644 --- a/examples/libraries/virtualBaton.21.js +++ b/examples/libraries/virtualBaton.25.js @@ -40,7 +40,7 @@ virtualBaton = function virtualBaton(options) { electionWatchdog, // paxos acceptor state bestProposal = {number: 0}, - accepted = null; + accepted = {}; if (!key) { throw new Error("A VirtualBaton must specify a key."); } @@ -54,7 +54,8 @@ virtualBaton = function virtualBaton(options) { } function doRelease() { var callback = releaseCallback, oldAccepted = accepted; - accepted = releaseCallback = undefined; + releaseCallback = undefined; + accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; debug('baton: doRelease', key, callback); if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. Messages.messageReceived.disconnect(messageHandler); @@ -68,7 +69,7 @@ virtualBaton = function virtualBaton(options) { // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer // (such as the server). - function acceptedId() { return accepted && accepted.winner; } + function acceptedId() { return accepted && accepted.winner; } // fixme doesn't need to be so fancy any more? // Paxos makes several tests of one "proposal number" versus another, assuming // that better proposals from the same proposer have a higher number, // and different proposers use a different set of numbers. We achieve that @@ -84,14 +85,13 @@ virtualBaton = function virtualBaton(options) { if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } if (!claimCallback) { return; } // We're not participating. nPromises = 0; - nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; + nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! bestPromise.proposerId = MyAvatar.sessionUUID; bestPromise.number++; bestPromise.winner = claim; send('prepare!', bestPromise); - electionWatchdog = Script.setTimeout(function () { - propose(claim); - }, electionTimeout); + function reclaim() { propose(claim); } + electionWatchdog = Script.setTimeout(reclaim, electionTimeout); } function messageHandler(messageChannel, messageString, senderID) { if (messageChannel !== channel) { return; } @@ -105,14 +105,15 @@ virtualBaton = function virtualBaton(options) { //FIXME bestPromise.number = Math.max(bestPromise.number, data.number); if (betterNumber(data, bestProposal)) { - var response = accepted || data; - if (!response.winner && claimCallback) { + bestProposal = data; + if (claimCallback) { // Optimization: Let the proposer know we're interested in the job if the proposer doesn't // know who else to pick. Avoids needing to start multiple simultaneous proposals. - response.interested = MyAvatar.sessionUUID; + accepted.interested = MyAvatar.sessionUUID; } - bestProposal = data; - send('promise', response); + send('promise', accepted.winner ? // data must include proposerId so that proposer catalogs results. + {number: accepted.number, proposerId: data.proposerId, winner: accepted.winner} : + {proposerId: data.proposerId}); } // FIXME nack? break; case 'promise': @@ -137,7 +138,7 @@ virtualBaton = function virtualBaton(options) { break; case 'accepted': accepted = data; - if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not been the proposer. + if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not have been the proposer. if (electionWatchdog) { Script.clearTimeout(electionWatchdog); electionWatchdog = null; diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 90f9219d09..8c92b320b3 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -12,7 +12,7 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // All participants should run the test script. -Script.include("../libraries/virtualBaton.21.js"); +Script.include("../libraries/virtualBaton.25.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); var ticker, countDown; From 59f1cdfc184484212c2d5b09fc3ec06c42622950 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Tue, 2 Feb 2016 16:18:33 -0800 Subject: [PATCH 07/24] Reinit bestPromise with each proposal. --- examples/libraries/{virtualBaton.25.js => virtualBaton.27.js} | 4 +--- examples/tests/testBaton.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) rename examples/libraries/{virtualBaton.25.js => virtualBaton.27.js} (98%) diff --git a/examples/libraries/virtualBaton.25.js b/examples/libraries/virtualBaton.27.js similarity index 98% rename from examples/libraries/virtualBaton.25.js rename to examples/libraries/virtualBaton.27.js index 90b04b8dda..9564f3f737 100644 --- a/examples/libraries/virtualBaton.25.js +++ b/examples/libraries/virtualBaton.27.js @@ -86,9 +86,7 @@ virtualBaton = function virtualBaton(options) { if (!claimCallback) { return; } // We're not participating. nPromises = 0; nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! - bestPromise.proposerId = MyAvatar.sessionUUID; - bestPromise.number++; - bestPromise.winner = claim; + bestPromise = {number: ++bestPromise.number, proposerId: MyAvatar.sessionUUID, winner: claim}; send('prepare!', bestPromise); function reclaim() { propose(claim); } electionWatchdog = Script.setTimeout(reclaim, electionTimeout); diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 8c92b320b3..bc6caa6f50 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -12,7 +12,7 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // All participants should run the test script. -Script.include("../libraries/virtualBaton.25.js"); +Script.include("../libraries/virtualBaton.27.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); var ticker, countDown; From 055de61ec64ef24a8e3af6e9fdddf2aef8e14d03 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Wed, 3 Feb 2016 11:10:04 -0800 Subject: [PATCH 08/24] update --- ...{virtualBaton.27.js => virtualBaton.29.js} | 76 +++++++++++-------- examples/tests/testBaton.js | 4 +- 2 files changed, 48 insertions(+), 32 deletions(-) rename examples/libraries/{virtualBaton.27.js => virtualBaton.29.js} (75%) diff --git a/examples/libraries/virtualBaton.27.js b/examples/libraries/virtualBaton.29.js similarity index 75% rename from examples/libraries/virtualBaton.27.js rename to examples/libraries/virtualBaton.29.js index 9564f3f737..6cdbbfe849 100644 --- a/examples/libraries/virtualBaton.27.js +++ b/examples/libraries/virtualBaton.29.js @@ -26,18 +26,21 @@ // Answers a new virtualBaton for the given parameters, of which 'key' // is required. virtualBaton = function virtualBaton(options) { - var key = options.key, + var key = options.key, channel = "io.highfidelity.virtualBaton:" + key, exports = options.exports || {}, claimCallback, releaseCallback, // paxos proposer state nPromises = 0, + proposalNumber = 0, nQuorum, mostRecentInterested, bestPromise = {number: 0}, - electionTimeout = options.electionTimeout || 1000, // ms. If no winner in this time, hold a new election + electionTimeout = options.electionTimeout || 1000, // ms. If no winner in this time, hold a new election. FIXME randomize electionWatchdog, + recheckInterval = options.recheckInterval || 1000, // ms. Check that winners remain connected. FIXME rnadomize + recheckWatchdog, // paxos acceptor state bestProposal = {number: 0}, accepted = {}; @@ -45,23 +48,26 @@ virtualBaton = function virtualBaton(options) { throw new Error("A VirtualBaton must specify a key."); } function debug() { - print.apply(null, [].map.call(arguments, JSON.stringify)); + print.apply(print, [].map.call(arguments, JSON.stringify)); // fixme no console + } + function debugFlow() { + if (options.debugFlow) { debug.apply(null, arguments); } } function send(operation, data) { - debug('baton: send', operation, data); + if (options.debugSend) { debug('baton:', MyAvatar.sessionUUID, '=>', '-', operation, data); } var message = JSON.stringify({op: operation, data: data}); Messages.sendMessage(channel, message); } - function doRelease() { + function localRelease() { var callback = releaseCallback, oldAccepted = accepted; releaseCallback = undefined; - accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; - debug('baton: doRelease', key, callback); + accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; // A copy without winner assigned, preserving number. + debugFlow('baton: localRelease', key, !!callback); if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. - Messages.messageReceived.disconnect(messageHandler); - Messages.unsubscribe(channel); // Messages currently allow publishing without subscription. - send('release', oldAccepted); // This order is less crufty. + //Messages.messageReceived.disconnect(messageHandler); + //Messages.unsubscribe(channel); // Messages currently allow publishing without subscription. callback(key); // Pass key so that clients may use the same handler for different batons. + return oldAccepted; } // Internally, this uses the Paxos algorith to hold elections. @@ -69,38 +75,37 @@ virtualBaton = function virtualBaton(options) { // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer // (such as the server). - function acceptedId() { return accepted && accepted.winner; } // fixme doesn't need to be so fancy any more? + function acceptedId() { return accepted && accepted.winner; } // Paxos makes several tests of one "proposal number" versus another, assuming // that better proposals from the same proposer have a higher number, // and different proposers use a different set of numbers. We achieve that // by dividing the "number" into two parts, and integer and a proposerId, // which keeps the combined number unique and yet still strictly ordered. function betterNumber(number, best) { - debug('baton: betterNumber', number, best); + // FIXME restore debug('baton: betterNumber', number, best); //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); return (number.number || 0) > best.number; } - function propose(claim) { - debug('baton: propose', claim); + function propose() { + debugFlow('baton:', MyAvatar.sessionUUID, 'propose', !!claimCallback); if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } if (!claimCallback) { return; } // We're not participating. nPromises = 0; + proposalNumber = Math.max(proposalNumber, bestPromise.number); nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! - bestPromise = {number: ++bestPromise.number, proposerId: MyAvatar.sessionUUID, winner: claim}; - send('prepare!', bestPromise); - function reclaim() { propose(claim); } - electionWatchdog = Script.setTimeout(reclaim, electionTimeout); + send('prepare!', {number: ++proposalNumber, proposerId: MyAvatar.sessionUUID}); + electionWatchdog = Script.setTimeout(propose, electionTimeout); } function messageHandler(messageChannel, messageString, senderID) { if (messageChannel !== channel) { return; } var message = JSON.parse(messageString), data = message.data; - debug('baton: received from', senderID, message.op, data); + if (options.debugReceive) { debug('baton:', senderID, '=>', MyAvatar.sessionUUID, message.op, data); } switch (message.op) { case 'prepare!': // Optimization: Don't waste time with low future proposals. // Does not remove the need for betterNumber() to consider proposerId, because // participants might not receive this prepare! message before their next proposal. - //FIXME bestPromise.number = Math.max(bestPromise.number, data.number); + proposalNumber = Math.max(proposalNumber, data.number); if (betterNumber(data, bestProposal)) { bestProposal = data; @@ -111,7 +116,7 @@ virtualBaton = function virtualBaton(options) { } send('promise', accepted.winner ? // data must include proposerId so that proposer catalogs results. {number: accepted.number, proposerId: data.proposerId, winner: accepted.winner} : - {proposerId: data.proposerId}); + {number: data.number, proposerId: data.proposerId}); } // FIXME nack? break; case 'promise': @@ -130,11 +135,12 @@ virtualBaton = function virtualBaton(options) { case 'accept!': if (!betterNumber(bestProposal, data)) { accepted = data; - send('accepted', accepted); + //send('accepted', accepted); // With the collapsed roles here, do we need this message? Maybe just go to 'accepted' case here? + messageHandler(messageChannel, JSON.stringify({op: 'accepted', data: accepted}), senderID); } - // FIXME: start interval (with a little random offset?) that claims if winner is ever not in AvatarList and we still claimCallback break; case 'accepted': + if (betterNumber(accepted, data)) { return; } accepted = data; if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not have been the proposer. if (electionWatchdog) { @@ -145,7 +151,7 @@ virtualBaton = function virtualBaton(options) { var callback = claimCallback; claimCallback = undefined; callback(key); - } else { // We won, but are no longer interested. + } else if (!releaseCallback) { // We won, but have been released and are no longer interested. propose(); // Propose that someone else take the job. } } @@ -153,12 +159,19 @@ virtualBaton = function virtualBaton(options) { case 'release': if (!betterNumber(accepted, data)) { // Unless our data is fresher... accepted.winner = undefined; // ... allow next proposer to have his way. + if (recheckWatchdog) { + Script.clearInterval(recheckWatchdog); + recheckWatchdog = null; + } } break; default: print("Unrecognized virtualBaton message:", message); } } + Messages.messageReceived.connect(messageHandler); // FIXME MUST BE DONE. quorum will be wrong if no one claims and we only subscrbe with claims + Messages.subscribe(channel); // FIXME + // Registers an intent to hold the baton: // Calls onElection(key) once, if you are elected by the scripts // to be the unique holder of the baton, which may be never. @@ -168,7 +181,7 @@ virtualBaton = function virtualBaton(options) { // You may claim again at any time after the start of onRelease // being called. Otherwise, you will not participate in further elections. exports.claim = function claim(onElection, onRelease) { - debug('baton: claim'); + debugFlow('baton:', MyAvatar.sessionUUID, 'claim'); if (claimCallback) { print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); return; @@ -179,14 +192,14 @@ virtualBaton = function virtualBaton(options) { } claimCallback = onElection; releaseCallback = onRelease; - Messages.messageReceived.connect(messageHandler); - Messages.subscribe(channel); - propose(MyAvatar.sessionUUID); + //Messages.messageReceived.connect(messageHandler); + //Messages.subscribe(channel); + propose(); }; // Release the baton you hold, or just log that you are not holding it. exports.release = function release(optionalReplacementOnRelease) { - debug('baton: release'); + debugFlow('baton:', MyAvatar.sessionUUID, 'release'); if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. releaseCallback = optionalReplacementOnRelease; } @@ -194,7 +207,10 @@ virtualBaton = function virtualBaton(options) { print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); return; } - doRelease(); + var released = localRelease(); + if (released) { + send('release', released); // Let everyone know right away, including old number in case we overlap with reclaim. + } if (!claimCallback) { // No claim set in release callback. propose(); // We are the distinguished proposer, but we'll pick anyone else interested. } diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index bc6caa6f50..9f4e4defca 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -12,9 +12,9 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // All participants should run the test script. -Script.include("../libraries/virtualBaton.27.js"); +Script.include("../libraries/virtualBaton.29.js"); var TICKER_INTERVAL = 1000; // ms -var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); +var baton = virtualBaton({key: 'io.highfidelity.testBaton', debugSend: true, debugFlow: true, debugReceive: true}); var ticker, countDown; // Tick every TICKER_INTERVAL. From 287d91d4b2932b440fdf45cd47a4699b5902b27c Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Wed, 3 Feb 2016 20:57:35 -0800 Subject: [PATCH 09/24] better --- examples/libraries/virtualBaton.29.js | 220 ---------------------- examples/libraries/virtualBaton.31.js | 252 ++++++++++++++++++++++++++ examples/tests/testBaton.js | 2 +- 3 files changed, 253 insertions(+), 221 deletions(-) delete mode 100644 examples/libraries/virtualBaton.29.js create mode 100644 examples/libraries/virtualBaton.31.js diff --git a/examples/libraries/virtualBaton.29.js b/examples/libraries/virtualBaton.29.js deleted file mode 100644 index 6cdbbfe849..0000000000 --- a/examples/libraries/virtualBaton.29.js +++ /dev/null @@ -1,220 +0,0 @@ -"use strict"; -/*jslint nomen: true, plusplus: true, vars: true */ -/*global Entities, Script, MyAvatar, Messages, AvatarList, print */ -// -// Created by Howard Stearns -// Copyright 2016 High Fidelity, Inc. -// -// Distributed under the Apache License, Version 2.0. -// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html -// -// Allows cooperating scripts to pass a "virtual baton" between them, -// which is useful when part of a script should only be executed by -// the one participant that is holding this particular baton. -// -// A virtual baton is simply any string agreed upon by the scripts -// that use it. Only one script at a time can hold the baton, and it -// holds it until that script releases it, or the other scripts -// determine that the holding script is not responding. The script -// automatically determines who among claimants has the baton, if anyone, -// and holds an "election" if necessary. -// -// See entityScript/tribble.js as an example, and the functions -// virtualBaton(), claim(), release(). -// - -// Answers a new virtualBaton for the given parameters, of which 'key' -// is required. -virtualBaton = function virtualBaton(options) { - var key = options.key, - channel = "io.highfidelity.virtualBaton:" + key, - exports = options.exports || {}, - claimCallback, - releaseCallback, - // paxos proposer state - nPromises = 0, - proposalNumber = 0, - nQuorum, - mostRecentInterested, - bestPromise = {number: 0}, - electionTimeout = options.electionTimeout || 1000, // ms. If no winner in this time, hold a new election. FIXME randomize - electionWatchdog, - recheckInterval = options.recheckInterval || 1000, // ms. Check that winners remain connected. FIXME rnadomize - recheckWatchdog, - // paxos acceptor state - bestProposal = {number: 0}, - accepted = {}; - if (!key) { - throw new Error("A VirtualBaton must specify a key."); - } - function debug() { - print.apply(print, [].map.call(arguments, JSON.stringify)); // fixme no console - } - function debugFlow() { - if (options.debugFlow) { debug.apply(null, arguments); } - } - function send(operation, data) { - if (options.debugSend) { debug('baton:', MyAvatar.sessionUUID, '=>', '-', operation, data); } - var message = JSON.stringify({op: operation, data: data}); - Messages.sendMessage(channel, message); - } - function localRelease() { - var callback = releaseCallback, oldAccepted = accepted; - releaseCallback = undefined; - accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; // A copy without winner assigned, preserving number. - debugFlow('baton: localRelease', key, !!callback); - if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. - //Messages.messageReceived.disconnect(messageHandler); - //Messages.unsubscribe(channel); // Messages currently allow publishing without subscription. - callback(key); // Pass key so that clients may use the same handler for different batons. - return oldAccepted; - } - - // Internally, this uses the Paxos algorith to hold elections. - // Alternatively, we could have the message server pick and maintain a winner, but it would - // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. - // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer - // (such as the server). - function acceptedId() { return accepted && accepted.winner; } - // Paxos makes several tests of one "proposal number" versus another, assuming - // that better proposals from the same proposer have a higher number, - // and different proposers use a different set of numbers. We achieve that - // by dividing the "number" into two parts, and integer and a proposerId, - // which keeps the combined number unique and yet still strictly ordered. - function betterNumber(number, best) { - // FIXME restore debug('baton: betterNumber', number, best); - //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); - return (number.number || 0) > best.number; - } - function propose() { - debugFlow('baton:', MyAvatar.sessionUUID, 'propose', !!claimCallback); - if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } - if (!claimCallback) { return; } // We're not participating. - nPromises = 0; - proposalNumber = Math.max(proposalNumber, bestPromise.number); - nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! - send('prepare!', {number: ++proposalNumber, proposerId: MyAvatar.sessionUUID}); - electionWatchdog = Script.setTimeout(propose, electionTimeout); - } - function messageHandler(messageChannel, messageString, senderID) { - if (messageChannel !== channel) { return; } - var message = JSON.parse(messageString), data = message.data; - if (options.debugReceive) { debug('baton:', senderID, '=>', MyAvatar.sessionUUID, message.op, data); } - switch (message.op) { - case 'prepare!': - // Optimization: Don't waste time with low future proposals. - // Does not remove the need for betterNumber() to consider proposerId, because - // participants might not receive this prepare! message before their next proposal. - proposalNumber = Math.max(proposalNumber, data.number); - - if (betterNumber(data, bestProposal)) { - bestProposal = data; - if (claimCallback) { - // Optimization: Let the proposer know we're interested in the job if the proposer doesn't - // know who else to pick. Avoids needing to start multiple simultaneous proposals. - accepted.interested = MyAvatar.sessionUUID; - } - send('promise', accepted.winner ? // data must include proposerId so that proposer catalogs results. - {number: accepted.number, proposerId: data.proposerId, winner: accepted.winner} : - {number: data.number, proposerId: data.proposerId}); - } // FIXME nack? - break; - case 'promise': - if (data.proposerId !== MyAvatar.sessionUUID) { return; } // Only the proposer needs to do anything. - mostRecentInterested = mostRecentInterested || data.interested; - if (betterNumber(data, bestPromise)) { - bestPromise = data; - } - if (++nPromises >= nQuorum) { - if (!bestPromise.winner) { // we get to pick - bestPromise.winner = claimCallback ? MyAvatar.sessionUUID : mostRecentInterested; - } - send('accept!', bestPromise); - } - break; - case 'accept!': - if (!betterNumber(bestProposal, data)) { - accepted = data; - //send('accepted', accepted); // With the collapsed roles here, do we need this message? Maybe just go to 'accepted' case here? - messageHandler(messageChannel, JSON.stringify({op: 'accepted', data: accepted}), senderID); - } - break; - case 'accepted': - if (betterNumber(accepted, data)) { return; } - accepted = data; - if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not have been the proposer. - if (electionWatchdog) { - Script.clearTimeout(electionWatchdog); - electionWatchdog = null; - } - if (claimCallback) { - var callback = claimCallback; - claimCallback = undefined; - callback(key); - } else if (!releaseCallback) { // We won, but have been released and are no longer interested. - propose(); // Propose that someone else take the job. - } - } - break; - case 'release': - if (!betterNumber(accepted, data)) { // Unless our data is fresher... - accepted.winner = undefined; // ... allow next proposer to have his way. - if (recheckWatchdog) { - Script.clearInterval(recheckWatchdog); - recheckWatchdog = null; - } - } - break; - default: - print("Unrecognized virtualBaton message:", message); - } - } - Messages.messageReceived.connect(messageHandler); // FIXME MUST BE DONE. quorum will be wrong if no one claims and we only subscrbe with claims - Messages.subscribe(channel); // FIXME - - // Registers an intent to hold the baton: - // Calls onElection(key) once, if you are elected by the scripts - // to be the unique holder of the baton, which may be never. - // Calls onRelease(key) once, if you release the baton held by you, - // whether this is by you calling release(), or by loosing - // an election when you become disconnected. - // You may claim again at any time after the start of onRelease - // being called. Otherwise, you will not participate in further elections. - exports.claim = function claim(onElection, onRelease) { - debugFlow('baton:', MyAvatar.sessionUUID, 'claim'); - if (claimCallback) { - print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); - return; - } - if (releaseCallback) { - print("Ignoring attempt to claim virtualBaton " + key + ", which is somehow incorrect released, and that should not happen."); - return; - } - claimCallback = onElection; - releaseCallback = onRelease; - //Messages.messageReceived.connect(messageHandler); - //Messages.subscribe(channel); - propose(); - }; - - // Release the baton you hold, or just log that you are not holding it. - exports.release = function release(optionalReplacementOnRelease) { - debugFlow('baton:', MyAvatar.sessionUUID, 'release'); - if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. - releaseCallback = optionalReplacementOnRelease; - } - if (acceptedId() !== MyAvatar.sessionUUID) { - print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); - return; - } - var released = localRelease(); - if (released) { - send('release', released); // Let everyone know right away, including old number in case we overlap with reclaim. - } - if (!claimCallback) { // No claim set in release callback. - propose(); // We are the distinguished proposer, but we'll pick anyone else interested. - } - }; - - return exports; -}; diff --git a/examples/libraries/virtualBaton.31.js b/examples/libraries/virtualBaton.31.js new file mode 100644 index 0000000000..5df4710d3e --- /dev/null +++ b/examples/libraries/virtualBaton.31.js @@ -0,0 +1,252 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true */ +/*global Entities, Script, MyAvatar, Messages, AvatarList, print */ +// +// Created by Howard Stearns +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +// Allows cooperating scripts to pass a "virtual baton" between them, +// which is useful when part of a script should only be executed by +// the one participant that is holding this particular baton. +// +// A virtual baton is simply any string agreed upon by the scripts +// that use it. Only one script at a time can hold the baton, and it +// holds it until that script releases it, or the other scripts +// determine that the holding script is not responding. The script +// automatically determines who among claimants has the baton, if anyone, +// and holds an "election" if necessary. +// +// See entityScript/tribble.js as an example, and the functions +// virtualBaton(), claim(), release(). +// + +// Answers a new virtualBaton for the given parameters, of which 'key' +// is required. +virtualBaton = function virtualBaton(options) { + // Answer averages (number +/- variability). Avoids having everyone act in lockstep. + function randomize(number, variability) { + var randomPart = number * variability; + return number - (randomPart / 2) + (Math.random() * randomPart); + } + var key = options.key, + useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations, + exports = options.exports || {}, + electionTimeout = options.electionTimeout || randomize(1000, 0.2), // ms. If no winner in this time, hold a new election. + claimCallback, + releaseCallback, + ourId = MyAvatar.sessionUUID; // better be stable! + if (!key) { + throw new Error("A VirtualBaton must specify a key."); + } + function debug() { + print.apply(null, [].map.call(arguments, JSON.stringify)); + } + function debugFlow() { + if (options.debugFlow) { debug.apply(null, arguments); } + } + + // Messages: Just synactic sugar for hooking things up to Messages system. + // We create separate subchannel strings for each operation within our general channelKey, instead of using + // a switch in the receiver. + var channelKey = "io.highfidelity.virtualBaton:" + key, + subchannelHandlers = {}, // Message channel string => {function, op} + subchannelKeys = {}; // operation => Message channel string + function subchannelKey(operation) { return channelKey + ':' + operation; } + function receive(operation, handler) { // Record a handler for an operation on our channelKey + var subKey = subchannelKey(operation); + subchannelHandlers[subKey] = {receiver: handler, op: operation}; + subchannelKeys[operation] = subKey; + Messages.subscribe(subKey); + } + function sendHelper(subchannel, data) { + var message = JSON.stringify(data); + Messages.sendMessage(subchannel, message); + } + function send1(operation, destination, data) { // Send data for an operation to just one destination on our channelKey. + if (options.debugSend) { debug('baton:', ourId, '=>', destination, operation, data); } + sendHelper(subchannelKey(operation) + destination, data); + } + function send(operation, data) { // Send data for an operation on our channelKey. + if (options.debugSend) { debug('baton:', ourId, '=>', '-', operation, data); } + sendHelper(subchannelKeys[operation], data); + } + Messages.messageReceived.connect(function (channel, messageString, senderID) { + var handler = subchannelHandlers[channel]; + if (!handler) { return; } + var data = JSON.parse(messageString); + if (options.debugReceive) { debug('baton:', senderID, '=>', ourId, handler.op, data); } + handler.receiver(data); + }); + + // Internally, this uses the Paxos algorith to hold elections. + // Alternatively, we could have the message server pick and maintain a winner, but it would + // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. + // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer + // (such as the server). + // + // Paxos makes several tests of one "proposal number" versus another, assuming + // that better proposals from the same proposer have a higher number, + // and different proposers use a different set of numbers. We achieve that + // by dividing the "number" into two parts, and integer and a proposerId, + // which keeps the combined number unique and yet still strictly ordered. + function betterNumber(number, best) { + // FIXME restore debug('baton: betterNumber', number, best); + //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); + return (number.number || 0) > best.number; + } + // Paxos Proposer behavior + var nPromises = 0, + proposalNumber = 0, + nQuorum, + mostRecentInterested, + bestPromise = {number: 0}, + electionWatchdog, + recheckInterval = options.recheckInterval || 1000, // ms. Check that winners remain connected. FIXME rnadomize + recheckWatchdog; + function propose() { + debugFlow('baton:', ourId, 'propose', !!claimCallback); + if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } + if (!claimCallback) { return; } // We're not participating. + electionWatchdog = Script.setTimeout(propose, electionTimeout); + nPromises = 0; + proposalNumber = Math.max(proposalNumber, bestPromise.number); + nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! + send('prepare!', {number: ++proposalNumber, proposerId: ourId}); + } + // We create a distinguished promise subchannel for our id, because promises need only be sent to the proposer. + receive('promise' + ourId, function (data) { + if (data.proposerId !== ourId) { return; } // Only the proposer needs to do anything. + mostRecentInterested = mostRecentInterested || data.interested; + if (betterNumber(data, bestPromise)) { + bestPromise = data; + } + if (++nPromises >= nQuorum) { + var answer = {number: data.proposalNumber, proposerId: data.proposerId, winner: data.winner}; + if (!answer.winner) { // we get to pick + answer.winner = claimCallback ? ourId : mostRecentInterested; + } + send('accept!', answer); + } + }); + // Paxos Acceptor behavior + var bestProposal = {number: 0}, accepted = {}; + function acceptedId() { return accepted && accepted.winner; } + receive('prepare!', function (data) { + if (useOptimizations) { // Don't waste time with low future proposals. + proposalNumber = Math.max(proposalNumber, data.number); + } + if (betterNumber(data, bestProposal)) { + bestProposal = data; + if (claimCallback && useOptimizations) { + // Let the proposer know we're interested in the job if the proposer doesn't + // know who else to pick. Avoids needing to start multiple simultaneous proposals. + accepted.interested = ourId; + } + send1('promise', data.proposerId, + accepted.winner ? // data must include proposerId and number so that proposer catalogs results. + {number: accepted.number, winner: accepted.winner, proposerId: data.proposerId, proposalNumber: data.number} : + {proposerId: data.proposerId, proposalNumber: data.number}); + } // FIXME nack? + }); + receive('accept!', function (data) { + if (!betterNumber(bestProposal, data)) { + bestProposal = data; + if (useOptimizations) { + // The Paxos literature describe every acceptor sending 'accepted' to + // every proposer and learner. In our case, these are the same nodes that received + // the 'accept!' message, so we can send to just the originating proposer and invoke + // our own accepted handler directly. + // Note that this optimization cannot be used with Byzantine Paxos (which needs another + // multi-broadcast to detect lying and collusion). + subchannelHandlers[subchannelKey('accepted') + ourId].receiver(data); + send1('accepted', data.proposerId, data); + } else { + send('accepted', data); + } + } + }); + // Paxos Learner behavior. + receive('accepted' + (useOptimizations ? ourId : ''), function (data) { // See note in 'accept!' regarding use of ourId here. + accepted = data; + if (acceptedId() === ourId) { // Note that we might not have been the proposer. + if (electionWatchdog) { + Script.clearTimeout(electionWatchdog); + electionWatchdog = null; + } + if (claimCallback) { + var callback = claimCallback; + claimCallback = undefined; + callback(key); + } else if (!releaseCallback) { // We won, but have been released and are no longer interested. + propose(); // Propose that someone else take the job. + } + } + }); + + receive('release', function (data) { + if (!betterNumber(accepted, data)) { // Unless our data is fresher... + debugFlow('baton:', ourId, 'handle release', data); + accepted.winner = null; // ... allow next proposer to have his way by making this explicitly null (not undefined). + if (recheckWatchdog) { + Script.clearInterval(recheckWatchdog); + recheckWatchdog = null; + } + } + }); + function localRelease() { + var callback = releaseCallback, oldAccepted = accepted; + releaseCallback = undefined; + accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; // A copy without winner assigned, preserving number. + debugFlow('baton: localRelease', key, !!callback); + if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. + callback(key); // Pass key so that clients may use the same handler for different batons. + return oldAccepted; + } + + // Registers an intent to hold the baton: + // Calls onElection(key) once, if you are elected by the scripts + // to be the unique holder of the baton, which may be never. + // Calls onRelease(key) once, if you release the baton held by you, + // whether this is by you calling release(), or by loosing + // an election when you become disconnected. + // You may claim again at any time after the start of onRelease + // being called. Otherwise, you will not participate in further elections. + exports.claim = function claim(onElection, onRelease) { + debugFlow('baton:', ourId, 'claim'); + if (claimCallback) { + print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); + return; + } + if (releaseCallback) { + print("Ignoring attempt to claim virtualBaton " + key + ", which is somehow incorrect released, and that should not happen."); + return; + } + claimCallback = onElection; + releaseCallback = onRelease; + propose(); + }; + + // Release the baton you hold, or just log that you are not holding it. + exports.release = function release(optionalReplacementOnRelease) { + debugFlow('baton:', ourId, 'release'); + if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. + releaseCallback = optionalReplacementOnRelease; + } + if (acceptedId() !== ourId) { + print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); + return; + } + var released = localRelease(); + if (released) { + send('release', released); // Let everyone know right away, including old number in case we overlap with reclaim. + } + if (!claimCallback) { // No claim set in release callback. + propose(); // We are the distinguished proposer, but we'll pick anyone else interested. + } + }; + + return exports; +}; diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 9f4e4defca..74fa0d39e4 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -12,7 +12,7 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // All participants should run the test script. -Script.include("../libraries/virtualBaton.29.js"); +Script.include("../libraries/virtualBaton.31.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton', debugSend: true, debugFlow: true, debugReceive: true}); var ticker, countDown; From dc50efe7ec9895ce0e446eab06907f59352fbb65 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 10:42:43 -0800 Subject: [PATCH 10/24] final? --- .../{tribble.js => tribble.x.34.js} | 37 +- examples/libraries/virtualBaton.31.js | 252 ------------- examples/libraries/virtualBaton.39.js | 354 ++++++++++++++++++ 3 files changed, 386 insertions(+), 257 deletions(-) rename examples/entityScripts/{tribble.js => tribble.x.34.js} (68%) delete mode 100644 examples/libraries/virtualBaton.31.js create mode 100644 examples/libraries/virtualBaton.39.js diff --git a/examples/entityScripts/tribble.js b/examples/entityScripts/tribble.x.34.js similarity index 68% rename from examples/entityScripts/tribble.js rename to examples/entityScripts/tribble.x.34.js index 3f84901344..145adfc325 100644 --- a/examples/entityScripts/tribble.js +++ b/examples/entityScripts/tribble.x.34.js @@ -1,5 +1,6 @@ (function () { // See tests/performance/tribbles.js + Script.include("../libraries/virtualBaton.39.js"); var dimensions, oldColor, entityID, editRate = 60, moveRate = 1, @@ -7,7 +8,8 @@ accumulated = 0, increment = {red: 1, green: 1, blue: 1}, hasUpdate = false, - shutdown = false; + shutdown = false, + baton; function nextWavelength(color) { var old = oldColor[color]; if (old === 255) { @@ -35,6 +37,22 @@ Entities.editEntity(entityID, newData); if (!shutdown) { Script.setTimeout(move, nextChange); } } + function startUpdate() { + print('startUpdate', entityID); + hasUpdate = true; + Script.update.connect(update); + } + function stopUpdate() { + print('stopUpdate', entityID, hasUpdate); + if (!hasUpdate) { return; } + hasUpdate = false; + Script.update.disconnect(update); + } + function stopUpdateAndReclaim() { + print('stopUpdateAndReclaim', entityID); + stopUpdate(); + baton.claim(startUpdate, stopUpdateAndReclaim); + } this.preload = function (givenEntityID) { entityID = givenEntityID; var properties = Entities.getEntityProperties(entityID); @@ -45,11 +63,19 @@ moveRate = (moveRate && userData.moveRate) || moveRate; oldColor = properties.color; dimensions = Vec3.multiply(scale, properties.dimensions); + baton = virtualBaton({ + // FIXME: batonName: 'io.highfidelity.tribble:' + entityID, + // If we wanted to have only one tribble change colors, we could do: + batonName: 'io.highfidelity.tribble', + instanceId: entityID + MyAvatar.sessionUUID, + debugFlow: true, + debugSend: true, + debugReceive: true + }); if (editTimeout) { - hasUpdate = true; - Script.update.connect(update); + baton.claim(startUpdate, stopUpdateAndReclaim); if (editTimeout > 0) { - Script.setTimeout(function () { Script.update.disconnect(update); hasUpdate = false; }, editTimeout * 1000); + Script.setTimeout(stopUpdate, editTimeout * 1000); } } if (moveTimeout) { @@ -60,7 +86,8 @@ } }; this.unload = function () { + baton.unload(); shutdown = true; - if (hasUpdate) { Script.update.disconnect(update); } + stopUpdate(); }; }) diff --git a/examples/libraries/virtualBaton.31.js b/examples/libraries/virtualBaton.31.js deleted file mode 100644 index 5df4710d3e..0000000000 --- a/examples/libraries/virtualBaton.31.js +++ /dev/null @@ -1,252 +0,0 @@ -"use strict"; -/*jslint nomen: true, plusplus: true, vars: true */ -/*global Entities, Script, MyAvatar, Messages, AvatarList, print */ -// -// Created by Howard Stearns -// Copyright 2016 High Fidelity, Inc. -// -// Distributed under the Apache License, Version 2.0. -// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html -// -// Allows cooperating scripts to pass a "virtual baton" between them, -// which is useful when part of a script should only be executed by -// the one participant that is holding this particular baton. -// -// A virtual baton is simply any string agreed upon by the scripts -// that use it. Only one script at a time can hold the baton, and it -// holds it until that script releases it, or the other scripts -// determine that the holding script is not responding. The script -// automatically determines who among claimants has the baton, if anyone, -// and holds an "election" if necessary. -// -// See entityScript/tribble.js as an example, and the functions -// virtualBaton(), claim(), release(). -// - -// Answers a new virtualBaton for the given parameters, of which 'key' -// is required. -virtualBaton = function virtualBaton(options) { - // Answer averages (number +/- variability). Avoids having everyone act in lockstep. - function randomize(number, variability) { - var randomPart = number * variability; - return number - (randomPart / 2) + (Math.random() * randomPart); - } - var key = options.key, - useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations, - exports = options.exports || {}, - electionTimeout = options.electionTimeout || randomize(1000, 0.2), // ms. If no winner in this time, hold a new election. - claimCallback, - releaseCallback, - ourId = MyAvatar.sessionUUID; // better be stable! - if (!key) { - throw new Error("A VirtualBaton must specify a key."); - } - function debug() { - print.apply(null, [].map.call(arguments, JSON.stringify)); - } - function debugFlow() { - if (options.debugFlow) { debug.apply(null, arguments); } - } - - // Messages: Just synactic sugar for hooking things up to Messages system. - // We create separate subchannel strings for each operation within our general channelKey, instead of using - // a switch in the receiver. - var channelKey = "io.highfidelity.virtualBaton:" + key, - subchannelHandlers = {}, // Message channel string => {function, op} - subchannelKeys = {}; // operation => Message channel string - function subchannelKey(operation) { return channelKey + ':' + operation; } - function receive(operation, handler) { // Record a handler for an operation on our channelKey - var subKey = subchannelKey(operation); - subchannelHandlers[subKey] = {receiver: handler, op: operation}; - subchannelKeys[operation] = subKey; - Messages.subscribe(subKey); - } - function sendHelper(subchannel, data) { - var message = JSON.stringify(data); - Messages.sendMessage(subchannel, message); - } - function send1(operation, destination, data) { // Send data for an operation to just one destination on our channelKey. - if (options.debugSend) { debug('baton:', ourId, '=>', destination, operation, data); } - sendHelper(subchannelKey(operation) + destination, data); - } - function send(operation, data) { // Send data for an operation on our channelKey. - if (options.debugSend) { debug('baton:', ourId, '=>', '-', operation, data); } - sendHelper(subchannelKeys[operation], data); - } - Messages.messageReceived.connect(function (channel, messageString, senderID) { - var handler = subchannelHandlers[channel]; - if (!handler) { return; } - var data = JSON.parse(messageString); - if (options.debugReceive) { debug('baton:', senderID, '=>', ourId, handler.op, data); } - handler.receiver(data); - }); - - // Internally, this uses the Paxos algorith to hold elections. - // Alternatively, we could have the message server pick and maintain a winner, but it would - // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. - // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer - // (such as the server). - // - // Paxos makes several tests of one "proposal number" versus another, assuming - // that better proposals from the same proposer have a higher number, - // and different proposers use a different set of numbers. We achieve that - // by dividing the "number" into two parts, and integer and a proposerId, - // which keeps the combined number unique and yet still strictly ordered. - function betterNumber(number, best) { - // FIXME restore debug('baton: betterNumber', number, best); - //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); - return (number.number || 0) > best.number; - } - // Paxos Proposer behavior - var nPromises = 0, - proposalNumber = 0, - nQuorum, - mostRecentInterested, - bestPromise = {number: 0}, - electionWatchdog, - recheckInterval = options.recheckInterval || 1000, // ms. Check that winners remain connected. FIXME rnadomize - recheckWatchdog; - function propose() { - debugFlow('baton:', ourId, 'propose', !!claimCallback); - if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } - if (!claimCallback) { return; } // We're not participating. - electionWatchdog = Script.setTimeout(propose, electionTimeout); - nPromises = 0; - proposalNumber = Math.max(proposalNumber, bestPromise.number); - nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! - send('prepare!', {number: ++proposalNumber, proposerId: ourId}); - } - // We create a distinguished promise subchannel for our id, because promises need only be sent to the proposer. - receive('promise' + ourId, function (data) { - if (data.proposerId !== ourId) { return; } // Only the proposer needs to do anything. - mostRecentInterested = mostRecentInterested || data.interested; - if (betterNumber(data, bestPromise)) { - bestPromise = data; - } - if (++nPromises >= nQuorum) { - var answer = {number: data.proposalNumber, proposerId: data.proposerId, winner: data.winner}; - if (!answer.winner) { // we get to pick - answer.winner = claimCallback ? ourId : mostRecentInterested; - } - send('accept!', answer); - } - }); - // Paxos Acceptor behavior - var bestProposal = {number: 0}, accepted = {}; - function acceptedId() { return accepted && accepted.winner; } - receive('prepare!', function (data) { - if (useOptimizations) { // Don't waste time with low future proposals. - proposalNumber = Math.max(proposalNumber, data.number); - } - if (betterNumber(data, bestProposal)) { - bestProposal = data; - if (claimCallback && useOptimizations) { - // Let the proposer know we're interested in the job if the proposer doesn't - // know who else to pick. Avoids needing to start multiple simultaneous proposals. - accepted.interested = ourId; - } - send1('promise', data.proposerId, - accepted.winner ? // data must include proposerId and number so that proposer catalogs results. - {number: accepted.number, winner: accepted.winner, proposerId: data.proposerId, proposalNumber: data.number} : - {proposerId: data.proposerId, proposalNumber: data.number}); - } // FIXME nack? - }); - receive('accept!', function (data) { - if (!betterNumber(bestProposal, data)) { - bestProposal = data; - if (useOptimizations) { - // The Paxos literature describe every acceptor sending 'accepted' to - // every proposer and learner. In our case, these are the same nodes that received - // the 'accept!' message, so we can send to just the originating proposer and invoke - // our own accepted handler directly. - // Note that this optimization cannot be used with Byzantine Paxos (which needs another - // multi-broadcast to detect lying and collusion). - subchannelHandlers[subchannelKey('accepted') + ourId].receiver(data); - send1('accepted', data.proposerId, data); - } else { - send('accepted', data); - } - } - }); - // Paxos Learner behavior. - receive('accepted' + (useOptimizations ? ourId : ''), function (data) { // See note in 'accept!' regarding use of ourId here. - accepted = data; - if (acceptedId() === ourId) { // Note that we might not have been the proposer. - if (electionWatchdog) { - Script.clearTimeout(electionWatchdog); - electionWatchdog = null; - } - if (claimCallback) { - var callback = claimCallback; - claimCallback = undefined; - callback(key); - } else if (!releaseCallback) { // We won, but have been released and are no longer interested. - propose(); // Propose that someone else take the job. - } - } - }); - - receive('release', function (data) { - if (!betterNumber(accepted, data)) { // Unless our data is fresher... - debugFlow('baton:', ourId, 'handle release', data); - accepted.winner = null; // ... allow next proposer to have his way by making this explicitly null (not undefined). - if (recheckWatchdog) { - Script.clearInterval(recheckWatchdog); - recheckWatchdog = null; - } - } - }); - function localRelease() { - var callback = releaseCallback, oldAccepted = accepted; - releaseCallback = undefined; - accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; // A copy without winner assigned, preserving number. - debugFlow('baton: localRelease', key, !!callback); - if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. - callback(key); // Pass key so that clients may use the same handler for different batons. - return oldAccepted; - } - - // Registers an intent to hold the baton: - // Calls onElection(key) once, if you are elected by the scripts - // to be the unique holder of the baton, which may be never. - // Calls onRelease(key) once, if you release the baton held by you, - // whether this is by you calling release(), or by loosing - // an election when you become disconnected. - // You may claim again at any time after the start of onRelease - // being called. Otherwise, you will not participate in further elections. - exports.claim = function claim(onElection, onRelease) { - debugFlow('baton:', ourId, 'claim'); - if (claimCallback) { - print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); - return; - } - if (releaseCallback) { - print("Ignoring attempt to claim virtualBaton " + key + ", which is somehow incorrect released, and that should not happen."); - return; - } - claimCallback = onElection; - releaseCallback = onRelease; - propose(); - }; - - // Release the baton you hold, or just log that you are not holding it. - exports.release = function release(optionalReplacementOnRelease) { - debugFlow('baton:', ourId, 'release'); - if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. - releaseCallback = optionalReplacementOnRelease; - } - if (acceptedId() !== ourId) { - print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); - return; - } - var released = localRelease(); - if (released) { - send('release', released); // Let everyone know right away, including old number in case we overlap with reclaim. - } - if (!claimCallback) { // No claim set in release callback. - propose(); // We are the distinguished proposer, but we'll pick anyone else interested. - } - }; - - return exports; -}; diff --git a/examples/libraries/virtualBaton.39.js b/examples/libraries/virtualBaton.39.js new file mode 100644 index 0000000000..726a5b2c49 --- /dev/null +++ b/examples/libraries/virtualBaton.39.js @@ -0,0 +1,354 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true */ +/*global Messages, Script, MyAvatar, AvatarList, Entities, print */ + +// Created by Howard Stearns +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +// Allows cooperating scripts to pass a "virtual baton" between them, +// which is useful when part of a script should only be executed by +// the one participant that is holding this particular baton. +// +// A virtual baton is simply any string agreed upon by the scripts +// that use it. Only one script at a time can hold the baton, and it +// holds it until that script releases it, or the other scripts +// determine that the holding script is not responding. The script +// automatically determines who among claimants has the baton, if anyone, +// and holds an "election" if necessary. +// +// See entityScript/tribble.js as an example, and the functions +// virtualBaton(), claim(), release(). +// + +// Answers a new virtualBaton for the given parameters, of which 'key' +// is required. +function virtualBatonf(options) { + // Answer averages (number +/- variability). Avoids having everyone act in lockstep. + function randomize(number, variability) { + var allowedDeviation = number * variability; + return number - allowedDeviation + (Math.random() * 2 * allowedDeviation); + } + // Allow testing outside in a harness of High Fidelity. + var globals = options.globals || {}, + messages = globals.Messages || Messages, + myAvatar = globals.MyAvatar || MyAvatar, + avatarList = globals.AvatarList || AvatarList, + entities = globals.Entities || Entities, + timers = globals.Script || Script, + log = globals.print || print; + + var batonName = options.batonName, // The identify of the baton. + // instanceId is the identify of this particular copy of the script among all copies using the same batonName + // in the domain. For example, if you wanted only one entity among multiple entity scripts to hold the baton, + // you could specify virtualBaton({batonName: 'someBatonName', id: MyAvatar.sessionUUID + entityID}). + instanceId = options.instanceId || myAvatar.sessionUUID, + // virtualBaton() returns the exports object with properties. You can pass in an object to be side-effected. + exports = options.exports || {}, + // Handy to set false if we believe the optimizations are wrong, or to use both values in a test harness. + useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations, + // + electionTimeout = options.electionTimeout || randomize(500, 0.2), // ms. If no winner in this time, hold a new election. + recheckInterval = options.recheckInterval || randomize(500, 0.2), // ms. Check that winners remain connected. + // If you supply your own instanceId, you can also supply a connectionTest to check that answers + // truthy iff the given id is still valid and connected, and is run at recheckInterval. You can use + // exports.validId (see below), and the default answers truthy if id is valid or a concatenation of two valid + // ids. (This handles the most common cases of instanceId being either (the default) MyAvatar.sessionUUID, an + // entityID, or the concatenation (in either order) of both.) + connectionTest = options.connectionTest || function connectionTest(id) { + var idLength = 38; + if (id.length === idLength) { return exports.validId(id); } + return (id.length === 2 * idLength) && exports.validId(id.slice(0, idLength)) && exports.validId(id.slice(idLength)); + }; + + if (!batonName) { + throw new Error("A virtualBaton must specify a batonName."); + } + // Truthy if id exists as either a connected avatar or valid entity. + exports.validId = function validId(id) { + var avatar = avatarList.getAvatar(id); + if (avatar && (avatar.sessionUUID === id)) { return true; } + var properties = entities.getEntityProperties(id, ['type']); + return properties && properties.type; + }; + + // Various logging, controllable through options. + function debug() { // Display the arguments not just [Object object]. + log.apply(null, [].map.call(arguments, JSON.stringify)); + } + function debugFlow() { + if (options.debugFlow) { debug.apply(null, arguments); } + } + function debugSend(destination, operation, data) { + if (options.debugSend) { debug('baton:', batonName, instanceId, 's=>', destination, operation, data); } + } + function debugReceive(senderID, operation, data) { // senderID is client sessionUUID -- not necessarily instanceID! + if (options.debugReceive) { debug('baton:', batonName, senderID, '=>r', instanceId, operation, data); } + } + + // Messages: Just synactic sugar for hooking things up to Messages system. + // We create separate subchannel strings for each operation within our general channelKey, instead of using + // a switch in the receiver. + var channelKey = "io.highfidelity.virtualBaton:" + batonName, + subchannelHandlers = {}, // Message channel string => {receiver, op} + subchannelKeys = {}; // operation => Message channel string + function subchannelKey(operation) { return channelKey + ':' + operation; } + function receive(operation, handler) { // Record a handler for an operation on our channelKey + var subKey = subchannelKey(operation); + subchannelHandlers[subKey] = {receiver: handler, op: operation}; + subchannelKeys[operation] = subKey; + messages.subscribe(subKey); + } + function sendHelper(subchannel, data) { + var message = JSON.stringify(data); + messages.sendMessage(subchannel, message); + } + function send1(operation, destination, data) { // Send data for an operation to just one destination on our channelKey. + debugSend(destination, operation, data); + sendHelper(subchannelKey(operation) + destination, data); + } + function send(operation, data) { // Send data for an operation on our channelKey. + debugSend('-', operation, data); + sendHelper(subchannelKeys[operation], data); + } + function messageHandler(channel, messageString, senderID) { + var handler = subchannelHandlers[channel]; + if (!handler) { return; } + var data = JSON.parse(messageString); + debugReceive(senderID, handler.op, data); + handler.receiver(data); + } + messages.messageReceived.connect(messageHandler); + + var nPromises = 0, nAccepted = 0, electionWatchdog; + + // It would be great if we had a way to know how many subscribers our channel has. Failing that... + var nNack = 0, previousNSubscribers = 0, lastGathering = 0, thisTimeout = electionTimeout; + function nSubscribers() { // Answer the number of subscribers - or zero to indicate that we have to wait. + // To find nQuorum, we need to know how many scripts are being run using this batonName, which isn't + // the same as the number of clients! + // + // If we overestimate by too much, we may fail to reach consensus, which triggers a new + // election proposal, so we take the number of acceptors to be the max(nPromises, nAccepted) + // + nNack reported in the previous round. + // + // If we understimate by too much, there can be different pockets on the Internet that each + // believe they have agreement on different holders of the baton, which is precisely what + // the virtualBaton is supposed to avoid. Therefore we need to allow 'nack' gather stragglers. + + var now = Date.now(), elapsed = now - lastGathering; + if (elapsed >= thisTimeout) { + previousNSubscribers = Math.max(nPromises, nAccepted) + nNack; + lastGathering = now; + } // ...otherwise we use the previous value unchanged. + + // On startup, we do one proposal that we cannot possibly win, so that we'll + // lock things up for timeout to gather the number of responses. + if (!previousNSubscribers) { + var LARGE_INTEGER = Number.MAX_SAFE_INTEGER || (-1 >>> 1); // QT doesn't define the ECMA constant. Max int will do for our purposes. + previousNSubscribers = LARGE_INTEGER; + } + return previousNSubscribers; + } + + // MAIN ALGORITHM + // + // Internally, this uses the Paxos algorith to hold elections. + // Alternatively, we could have the message server pick and maintain a winner, but it would + // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages. + // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer + // (such as the server). + function betterNumber(number, best) { + return (number.number || 0) > best.number; + } + // Paxos Proposer behavior + var proposalNumber = 0, + nQuorum = 0, + bestPromise = {number: 0}, + claimCallback, + releaseCallback; + function propose() { // Make a new proposal, so that we learn/update the proposalNumber and winner. + // Even though we send back a 'nack' if the proposal is obsolete, with net work errors + // there's no way to know for certain that we've failed. The electionWatchdog will try a new + // proposal if we have not been accepted by a quorum after election Timeout. + if (electionWatchdog) { + // If we had a means of determining nSubscribers other than by counting, we could just + // timers.clearTimeout(electionWatchdog) and not return. + return; + } + thisTimeout = randomize(electionTimeout, 0.5); // for accurate nSubcribers counting. + electionWatchdog = timers.setTimeout(function () { + electionWatchdog = null; + propose(); + }, thisTimeout); + var nAcceptors = nSubscribers(); + nQuorum = Math.floor(nAcceptors / 2) + 1; + + proposalNumber = Math.max(proposalNumber, bestPromise.number) + 1; + debugFlow('baton:', batonName, instanceId, 'propose', proposalNumber, + 'claim:', !!claimCallback, 'nAcceptors:', nAcceptors, nPromises, nAccepted, nNack); + nPromises = nAccepted = nNack = 0; + send('prepare!', {number: proposalNumber, proposerId: instanceId}); + } + // We create a distinguished promise subchannel for our id, because promises need only be sent to the proposer. + receive('promise' + instanceId, function (data) { + if (betterNumber(data, bestPromise)) { + bestPromise = data; + } + if ((data.proposalNumber === proposalNumber) && (++nPromises >= nQuorum)) { // Note check for not being a previous round + var answer = {number: data.proposalNumber, proposerId: data.proposerId, winner: bestPromise.winner}; // Not data.number. + if (!answer.winner || (answer.winner === instanceId)) { // We get to pick. + answer.winner = claimCallback ? instanceId : null; + } + send('accept!', answer); + } + }); + receive('nack' + instanceId, function (data) { // An acceptor reports more recent data... + if (data.proposalNumber === proposalNumber) { + nNack++; // For updating nQuorum. + // IWBNI if we started our next proposal now (here, in a setTimeout, but we need a decent nNack count. + // Lets save that optimization for another day... + } + }); + // Paxos Acceptor behavior + var bestProposal = {number: 0}, accepted = {}; + function acceptedId() { return accepted && accepted.winner; } + receive('prepare!', function (data) { + var response = {proposalNumber: data.number, proposerId: data.proposerId}; + if (betterNumber(data, bestProposal)) { + bestProposal = data; + if (accepted.winner) { + response.number = accepted.number; + response.winner = accepted.winner; + } + send1('promise', data.proposerId, response); + } else { + send1('nack', data.proposerId, response); + } + }); + receive('accept!', function (data) { + if (!betterNumber(bestProposal, data)) { + bestProposal = accepted = data; // Update both with current data. Might have missed the proposal earlier. + if (useOptimizations) { + // The Paxos literature describe every acceptor sending 'accepted' to + // every proposer and learner. In our case, these are the same nodes that received + // the 'accept!' message, so we can send to just the originating proposer and invoke + // our own accepted handler directly. + // Note that this optimization cannot be used with Byzantine Paxos (which needs another + // multi-broadcast to detect lying and collusion). + debugSend('/', 'accepted', data); + debugReceive(instanceId, 'accepted', data); // direct on next line, which doesn't get logging. + subchannelHandlers[subchannelKey('accepted') + instanceId].receiver(data); + if (data.proposerId !== instanceId) { // i.e., we didn't already do it directly on the line above. + send1('accepted', data.proposerId, data); + } + } else { + send('accepted', data); + } + } else { + send1('nack', data.proposerId, {proposalNumber: data.number}); + } + }); + // Paxos Learner behavior. + function localRelease() { + var callback = releaseCallback; + debugFlow('baton:', batonName, 'localRelease', 'callback:', !!releaseCallback); + if (!releaseCallback) { return; } // Already released, but we might still receive a stale message. That's ok. + releaseCallback = undefined; + callback(batonName); // Pass batonName so that clients may use the same handler for different batons. + } + receive('accepted' + (useOptimizations ? instanceId : ''), function (data) { // See note in 'accept!' regarding use of instanceId here. + if (betterNumber(accepted, data)) { // Especially when !useOptimizations, we can receive other acceptances late. + return; + } + var oldAccepted = accepted; + debugFlow('baton:', batonName, instanceId, 'accepted', data.number, data.winner); + accepted = data; + // If we are proposer, make sure we get a quorum of acceptances. + if ((data.proposerId === instanceId) && (data.number === proposalNumber) && (++nAccepted >= nQuorum)) { + if (electionWatchdog) { + timers.clearTimeout(electionWatchdog); + electionWatchdog = null; + } + } + // If we are the winner -- regardless of whether we were the proposer. + if (acceptedId() === instanceId) { + if (claimCallback) { + var callback = claimCallback; + claimCallback = undefined; + callback(batonName); + } else if (!releaseCallback) { // We won, but have been released and are no longer interested. + // Propose that someone else take the job. + timers.setTimeout(propose, 0); // Asynchronous to queue message handling if some are synchronous and others not. + } + } else if (releaseCallback && (oldAccepted.winner === instanceId)) { // We've been released by someone else! + localRelease(); // This can happen if enough people thought we'd disconnected. + } + }); + + // Registers an intent to hold the baton: + // Calls onElection(batonName) once, if you are elected by the scripts + // to be the unique holder of the baton, which may be never. + // Calls onRelease(batonName) once, if you release the baton held by you, + // whether this is by you calling release(), or by loosing + // an election when you become disconnected. + // You may claim again at any time after the start of onRelease + // being called. Otherwise, you will not participate in further elections. + exports.claim = function claim(onElection, onRelease) { + debugFlow('baton:', batonName, instanceId, 'claim'); + if (claimCallback) { + log("Ignoring attempt to claim virtualBaton " + batonName + ", which is already waiting for claim."); + return; + } + if (releaseCallback) { + log("Ignoring attempt to claim virtualBaton " + batonName + ", which is somehow incorrect released, and that should not happen."); + return; + } + claimCallback = onElection; + releaseCallback = onRelease; + propose(); + return exports; // Allows chaining. e.g., var baton = virtualBaton({batonName: 'foo'}.claim(onClaim, onRelease); + }; + // Release the baton you hold, or just log that you are not holding it. + exports.release = function release(optionalReplacementOnRelease) { + debugFlow('baton:', batonName, instanceId, 'release'); + if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. + releaseCallback = optionalReplacementOnRelease; + } + if (acceptedId() !== instanceId) { + log("Ignoring attempt to release virtualBaton " + batonName + ", which is not being held."); + return; + } + localRelease(); + if (!claimCallback) { // No claim set in release callback. + propose(); // We are the distinguished proposer, but we'll pick anyone else interested, else set it to null. + } + return exports; + }; + exports.recheckWatchdog = timers.setInterval(function recheck() { + var holder = acceptedId(); // If we're waiting and we notice the holder is gone, ... + if (holder && claimCallback && !electionWatchdog && !connectionTest(holder)) { + propose(); // ... propose an election. + } + }, recheckInterval); + exports.unload = function unload() { // Disconnect from everything. + messages.messageReceived.disconnect(messageHandler); + timers.clearInterval(exports.recheckWatchdog); + if (electionWatchdog) { timers.clearTimeout(electionWatchdog); } + electionWatchdog = claimCallback = releaseCallback = null; + Object.keys(subchannelHandlers).forEach(messages.unsubscribe); + debugFlow('baton:', batonName, instanceId, 'unload'); + return exports; + }; + + // Gather nAcceptors by making two proposals with some gathering time, even without a claim. + propose(); + return exports; +} +if (typeof module !== 'undefined') { + module.exports = virtualBatonf; +} else { + virtualBaton = virtualBatonf; +} From 7b39700136d88ac685afb3637615b8e65d91ca7f Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 13:33:21 -0800 Subject: [PATCH 11/24] one ring to rule --- .../entityScripts/{tribble.x.34.js => tribble.x.35.js} | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) rename examples/entityScripts/{tribble.x.34.js => tribble.x.35.js} (92%) diff --git a/examples/entityScripts/tribble.x.34.js b/examples/entityScripts/tribble.x.35.js similarity index 92% rename from examples/entityScripts/tribble.x.34.js rename to examples/entityScripts/tribble.x.35.js index 145adfc325..a65f91b171 100644 --- a/examples/entityScripts/tribble.x.34.js +++ b/examples/entityScripts/tribble.x.35.js @@ -64,13 +64,10 @@ oldColor = properties.color; dimensions = Vec3.multiply(scale, properties.dimensions); baton = virtualBaton({ - // FIXME: batonName: 'io.highfidelity.tribble:' + entityID, - // If we wanted to have only one tribble change colors, we could do: batonName: 'io.highfidelity.tribble', - instanceId: entityID + MyAvatar.sessionUUID, debugFlow: true, - debugSend: true, - debugReceive: true + debugSend: false, + debugReceive: false }); if (editTimeout) { baton.claim(startUpdate, stopUpdateAndReclaim); From 8f714980df2e371c040fd113209f44f74c34bbd3 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 13:49:46 -0800 Subject: [PATCH 12/24] debuggable --- .../entityScripts/{tribble.x.35.js => tribble.x.36.js} | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) rename examples/entityScripts/{tribble.x.35.js => tribble.x.36.js} (95%) diff --git a/examples/entityScripts/tribble.x.35.js b/examples/entityScripts/tribble.x.36.js similarity index 95% rename from examples/entityScripts/tribble.x.35.js rename to examples/entityScripts/tribble.x.36.js index a65f91b171..a4c6f76224 100644 --- a/examples/entityScripts/tribble.x.35.js +++ b/examples/entityScripts/tribble.x.36.js @@ -59,15 +59,16 @@ var userData = properties.userData && JSON.parse(properties.userData); var moveTimeout = userData ? userData.moveTimeout : 0; var editTimeout = userData ? userData.editTimeout : 0; + var debug = (userData && userData.debug) || {}; editRate = (userData && userData.editRate) || editRate; moveRate = (moveRate && userData.moveRate) || moveRate; oldColor = properties.color; dimensions = Vec3.multiply(scale, properties.dimensions); baton = virtualBaton({ batonName: 'io.highfidelity.tribble', - debugFlow: true, - debugSend: false, - debugReceive: false + debugFlow: debug.flow, + debugSend: debug.send, + debugReceive: debug.receive }); if (editTimeout) { baton.claim(startUpdate, stopUpdateAndReclaim); From 1a0017900c3b0d155e1626976966906f04b74f54 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 14:10:55 -0800 Subject: [PATCH 13/24] noisy connectionTest --- examples/entityScripts/{tribble.x.36.js => tribble.x.37.js} | 5 +++++ 1 file changed, 5 insertions(+) rename examples/entityScripts/{tribble.x.36.js => tribble.x.37.js} (94%) diff --git a/examples/entityScripts/tribble.x.36.js b/examples/entityScripts/tribble.x.37.js similarity index 94% rename from examples/entityScripts/tribble.x.36.js rename to examples/entityScripts/tribble.x.37.js index a4c6f76224..05f7154fd1 100644 --- a/examples/entityScripts/tribble.x.36.js +++ b/examples/entityScripts/tribble.x.37.js @@ -66,6 +66,11 @@ dimensions = Vec3.multiply(scale, properties.dimensions); baton = virtualBaton({ batonName: 'io.highfidelity.tribble', + connectionTest: function (id) { + var connected = baton.validId(id); + print(MyAvatar.sessionUUID, id, connected); + return connected; + }, debugFlow: debug.flow, debugSend: debug.send, debugReceive: debug.receive From 8b27b90566734ed56952b6d5dc2430fc088ce6ac Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 14:57:29 -0800 Subject: [PATCH 14/24] Verify validId before reporting a winning reponse to a proposal. --- examples/entityScripts/{tribble.x.37.js => tribble.x.38.js} | 2 +- .../libraries/{virtualBaton.39.js => virtualBaton.40.js} | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) rename examples/entityScripts/{tribble.x.37.js => tribble.x.38.js} (98%) rename examples/libraries/{virtualBaton.39.js => virtualBaton.40.js} (98%) diff --git a/examples/entityScripts/tribble.x.37.js b/examples/entityScripts/tribble.x.38.js similarity index 98% rename from examples/entityScripts/tribble.x.37.js rename to examples/entityScripts/tribble.x.38.js index 05f7154fd1..3031ec6cd6 100644 --- a/examples/entityScripts/tribble.x.37.js +++ b/examples/entityScripts/tribble.x.38.js @@ -1,6 +1,6 @@ (function () { // See tests/performance/tribbles.js - Script.include("../libraries/virtualBaton.39.js"); + Script.include("../libraries/virtualBaton.40.js"); var dimensions, oldColor, entityID, editRate = 60, moveRate = 1, diff --git a/examples/libraries/virtualBaton.39.js b/examples/libraries/virtualBaton.40.js similarity index 98% rename from examples/libraries/virtualBaton.39.js rename to examples/libraries/virtualBaton.40.js index 726a5b2c49..8453365c9d 100644 --- a/examples/libraries/virtualBaton.39.js +++ b/examples/libraries/virtualBaton.40.js @@ -219,7 +219,10 @@ function virtualBatonf(options) { var response = {proposalNumber: data.number, proposerId: data.proposerId}; if (betterNumber(data, bestProposal)) { bestProposal = data; - if (accepted.winner) { + // For stability, we don't let any one proposer rule out a disconnnected winner. + // If someone notices that a winner has disconnected (in their recheckWatchdog), + // they call for a new election. To remain chosen, a quorum need to confirm here. + if (accepted.winner && connectionTest(accepted.winner)) { response.number = accepted.number; response.winner = accepted.winner; } From b6472217c4193fce6ffef96478443cf596ea4336 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 16:08:40 -0800 Subject: [PATCH 15/24] Clear accepted/bestPromise .winner when disconnected. --- examples/entityScripts/{tribble.x.38.js => tribble.x.39.js} | 2 +- .../libraries/{virtualBaton.40.js => virtualBaton.41.js} | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) rename examples/entityScripts/{tribble.x.38.js => tribble.x.39.js} (98%) rename examples/libraries/{virtualBaton.40.js => virtualBaton.41.js} (98%) diff --git a/examples/entityScripts/tribble.x.38.js b/examples/entityScripts/tribble.x.39.js similarity index 98% rename from examples/entityScripts/tribble.x.38.js rename to examples/entityScripts/tribble.x.39.js index 3031ec6cd6..804666d344 100644 --- a/examples/entityScripts/tribble.x.38.js +++ b/examples/entityScripts/tribble.x.39.js @@ -1,6 +1,6 @@ (function () { // See tests/performance/tribbles.js - Script.include("../libraries/virtualBaton.40.js"); + Script.include("../libraries/virtualBaton.41.js"); var dimensions, oldColor, entityID, editRate = 60, moveRate = 1, diff --git a/examples/libraries/virtualBaton.40.js b/examples/libraries/virtualBaton.41.js similarity index 98% rename from examples/libraries/virtualBaton.40.js rename to examples/libraries/virtualBaton.41.js index 8453365c9d..a472a9fdda 100644 --- a/examples/libraries/virtualBaton.40.js +++ b/examples/libraries/virtualBaton.41.js @@ -219,10 +219,7 @@ function virtualBatonf(options) { var response = {proposalNumber: data.number, proposerId: data.proposerId}; if (betterNumber(data, bestProposal)) { bestProposal = data; - // For stability, we don't let any one proposer rule out a disconnnected winner. - // If someone notices that a winner has disconnected (in their recheckWatchdog), - // they call for a new election. To remain chosen, a quorum need to confirm here. - if (accepted.winner && connectionTest(accepted.winner)) { + if (accepted.winner) { response.number = accepted.number; response.winner = accepted.winner; } @@ -333,6 +330,7 @@ function virtualBatonf(options) { exports.recheckWatchdog = timers.setInterval(function recheck() { var holder = acceptedId(); // If we're waiting and we notice the holder is gone, ... if (holder && claimCallback && !electionWatchdog && !connectionTest(holder)) { + accepted.winner = bestPromise.winner = null; propose(); // ... propose an election. } }, recheckInterval); From e52b910e532469be8da6894c1aa73d4b1d1095c4 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 16:39:29 -0800 Subject: [PATCH 16/24] connectionTest --- examples/entityScripts/{tribble.x.39.js => tribble.x.40.js} | 2 +- examples/libraries/{virtualBaton.41.js => virtualBaton.42.js} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename examples/entityScripts/{tribble.x.39.js => tribble.x.40.js} (98%) rename examples/libraries/{virtualBaton.41.js => virtualBaton.42.js} (99%) diff --git a/examples/entityScripts/tribble.x.39.js b/examples/entityScripts/tribble.x.40.js similarity index 98% rename from examples/entityScripts/tribble.x.39.js rename to examples/entityScripts/tribble.x.40.js index 804666d344..7dd2fe3aa5 100644 --- a/examples/entityScripts/tribble.x.39.js +++ b/examples/entityScripts/tribble.x.40.js @@ -1,6 +1,6 @@ (function () { // See tests/performance/tribbles.js - Script.include("../libraries/virtualBaton.41.js"); + Script.include("../libraries/virtualBaton.42.js"); var dimensions, oldColor, entityID, editRate = 60, moveRate = 1, diff --git a/examples/libraries/virtualBaton.41.js b/examples/libraries/virtualBaton.42.js similarity index 99% rename from examples/libraries/virtualBaton.41.js rename to examples/libraries/virtualBaton.42.js index a472a9fdda..fd81671c44 100644 --- a/examples/libraries/virtualBaton.41.js +++ b/examples/libraries/virtualBaton.42.js @@ -219,7 +219,7 @@ function virtualBatonf(options) { var response = {proposalNumber: data.number, proposerId: data.proposerId}; if (betterNumber(data, bestProposal)) { bestProposal = data; - if (accepted.winner) { + if (accepted.winner && connectionTest(accepted.winner)) { response.number = accepted.number; response.winner = accepted.winner; } @@ -330,7 +330,7 @@ function virtualBatonf(options) { exports.recheckWatchdog = timers.setInterval(function recheck() { var holder = acceptedId(); // If we're waiting and we notice the holder is gone, ... if (holder && claimCallback && !electionWatchdog && !connectionTest(holder)) { - accepted.winner = bestPromise.winner = null; + bestPromise.winner = null; // used if the quorum agrees that old winner is not there propose(); // ... propose an election. } }, recheckInterval); From b08f3644474af7185650c33a5fd61b244fd89109 Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Sun, 7 Feb 2016 18:29:10 -0800 Subject: [PATCH 17/24] yes! --- .../{tribble.x.40.js => tribble.js} | 5 - .../{virtualBaton.42.js => virtualBaton.js} | 47 ++-- examples/tests/performance/tribbles.js | 7 +- tests/mocha/README.md | 5 + tests/mocha/package.json | 11 + tests/mocha/test/testVirtualBaton.js | 220 ++++++++++++++++++ 6 files changed, 265 insertions(+), 30 deletions(-) rename examples/entityScripts/{tribble.x.40.js => tribble.js} (94%) rename examples/libraries/{virtualBaton.42.js => virtualBaton.js} (91%) create mode 100644 tests/mocha/README.md create mode 100644 tests/mocha/package.json create mode 100644 tests/mocha/test/testVirtualBaton.js diff --git a/examples/entityScripts/tribble.x.40.js b/examples/entityScripts/tribble.js similarity index 94% rename from examples/entityScripts/tribble.x.40.js rename to examples/entityScripts/tribble.js index 7dd2fe3aa5..a5aaf4feb6 100644 --- a/examples/entityScripts/tribble.x.40.js +++ b/examples/entityScripts/tribble.js @@ -66,11 +66,6 @@ dimensions = Vec3.multiply(scale, properties.dimensions); baton = virtualBaton({ batonName: 'io.highfidelity.tribble', - connectionTest: function (id) { - var connected = baton.validId(id); - print(MyAvatar.sessionUUID, id, connected); - return connected; - }, debugFlow: debug.flow, debugSend: debug.send, debugReceive: debug.receive diff --git a/examples/libraries/virtualBaton.42.js b/examples/libraries/virtualBaton.js similarity index 91% rename from examples/libraries/virtualBaton.42.js rename to examples/libraries/virtualBaton.js index fd81671c44..b1f813f764 100644 --- a/examples/libraries/virtualBaton.42.js +++ b/examples/libraries/virtualBaton.js @@ -31,7 +31,8 @@ function virtualBatonf(options) { var allowedDeviation = number * variability; return number - allowedDeviation + (Math.random() * 2 * allowedDeviation); } - // Allow testing outside in a harness of High Fidelity. + // Allow testing outside in a harness outside of High Fidelity. + // See sourceCodeSandbox/tests/mocha/test/testVirtualBaton.js var globals = options.globals || {}, messages = globals.Messages || Messages, myAvatar = globals.MyAvatar || MyAvatar, @@ -43,20 +44,20 @@ function virtualBatonf(options) { var batonName = options.batonName, // The identify of the baton. // instanceId is the identify of this particular copy of the script among all copies using the same batonName // in the domain. For example, if you wanted only one entity among multiple entity scripts to hold the baton, - // you could specify virtualBaton({batonName: 'someBatonName', id: MyAvatar.sessionUUID + entityID}). + // you could specify virtualBaton({batonName: 'someBatonName', instanceId: MyAvatar.sessionUUID + entityID}). instanceId = options.instanceId || myAvatar.sessionUUID, // virtualBaton() returns the exports object with properties. You can pass in an object to be side-effected. exports = options.exports || {}, // Handy to set false if we believe the optimizations are wrong, or to use both values in a test harness. useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations, - // electionTimeout = options.electionTimeout || randomize(500, 0.2), // ms. If no winner in this time, hold a new election. recheckInterval = options.recheckInterval || randomize(500, 0.2), // ms. Check that winners remain connected. - // If you supply your own instanceId, you can also supply a connectionTest to check that answers - // truthy iff the given id is still valid and connected, and is run at recheckInterval. You can use - // exports.validId (see below), and the default answers truthy if id is valid or a concatenation of two valid - // ids. (This handles the most common cases of instanceId being either (the default) MyAvatar.sessionUUID, an - // entityID, or the concatenation (in either order) of both.) + // If you supply your own instanceId, you might also supply a connectionTest that answers + // truthy iff the given id is still valid and connected, and is run at recheckInterval. You + // can use exports.validId (see below), and the default answers truthy if id is valid or a + // concatenation of two valid ids. (This handles the most common cases of instanceId being + // either (the default) MyAvatar.sessionUUID, an entityID, or the concatenation (in either + // order) of both.) connectionTest = options.connectionTest || function connectionTest(id) { var idLength = 38; if (id.length === idLength) { return exports.validId(id); } @@ -126,7 +127,7 @@ function virtualBatonf(options) { // It would be great if we had a way to know how many subscribers our channel has. Failing that... var nNack = 0, previousNSubscribers = 0, lastGathering = 0, thisTimeout = electionTimeout; - function nSubscribers() { // Answer the number of subscribers - or zero to indicate that we have to wait. + function nSubscribers() { // Answer the number of subscribers. // To find nQuorum, we need to know how many scripts are being run using this batonName, which isn't // the same as the number of clients! // @@ -136,7 +137,7 @@ function virtualBatonf(options) { // // If we understimate by too much, there can be different pockets on the Internet that each // believe they have agreement on different holders of the baton, which is precisely what - // the virtualBaton is supposed to avoid. Therefore we need to allow 'nack' gather stragglers. + // the virtualBaton is supposed to avoid. Therefore we need to allow 'nack' to gather stragglers. var now = Date.now(), elapsed = now - lastGathering; if (elapsed >= thisTimeout) { @@ -144,8 +145,8 @@ function virtualBatonf(options) { lastGathering = now; } // ...otherwise we use the previous value unchanged. - // On startup, we do one proposal that we cannot possibly win, so that we'll - // lock things up for timeout to gather the number of responses. + // On startup, we do one proposal that we cannot possibly close, so that we'll + // lock things up for the full electionTimeout to gather responses. if (!previousNSubscribers) { var LARGE_INTEGER = Number.MAX_SAFE_INTEGER || (-1 >>> 1); // QT doesn't define the ECMA constant. Max int will do for our purposes. previousNSubscribers = LARGE_INTEGER; @@ -170,7 +171,7 @@ function virtualBatonf(options) { claimCallback, releaseCallback; function propose() { // Make a new proposal, so that we learn/update the proposalNumber and winner. - // Even though we send back a 'nack' if the proposal is obsolete, with net work errors + // Even though we send back a 'nack' if the proposal is obsolete, with network errors // there's no way to know for certain that we've failed. The electionWatchdog will try a new // proposal if we have not been accepted by a quorum after election Timeout. if (electionWatchdog) { @@ -178,7 +179,7 @@ function virtualBatonf(options) { // timers.clearTimeout(electionWatchdog) and not return. return; } - thisTimeout = randomize(electionTimeout, 0.5); // for accurate nSubcribers counting. + thisTimeout = randomize(electionTimeout, 0.5); // Note use in nSubcribers. electionWatchdog = timers.setTimeout(function () { electionWatchdog = null; propose(); @@ -208,7 +209,7 @@ function virtualBatonf(options) { receive('nack' + instanceId, function (data) { // An acceptor reports more recent data... if (data.proposalNumber === proposalNumber) { nNack++; // For updating nQuorum. - // IWBNI if we started our next proposal now (here, in a setTimeout, but we need a decent nNack count. + // IWBNI if we started our next proposal right now/here, but we need a decent nNack count. // Lets save that optimization for another day... } }); @@ -232,7 +233,7 @@ function virtualBatonf(options) { if (!betterNumber(bestProposal, data)) { bestProposal = accepted = data; // Update both with current data. Might have missed the proposal earlier. if (useOptimizations) { - // The Paxos literature describe every acceptor sending 'accepted' to + // The Paxos literature describes every acceptor sending 'accepted' to // every proposer and learner. In our case, these are the same nodes that received // the 'accept!' message, so we can send to just the originating proposer and invoke // our own accepted handler directly. @@ -288,14 +289,16 @@ function virtualBatonf(options) { } }); + // Public Interface + // // Registers an intent to hold the baton: // Calls onElection(batonName) once, if you are elected by the scripts // to be the unique holder of the baton, which may be never. - // Calls onRelease(batonName) once, if you release the baton held by you, - // whether this is by you calling release(), or by loosing + // Calls onRelease(batonName) once, if the baton held by you is released, + // whether this is by you calling release(), or by losing // an election when you become disconnected. // You may claim again at any time after the start of onRelease - // being called. Otherwise, you will not participate in further elections. + // being called. exports.claim = function claim(onElection, onRelease) { debugFlow('baton:', batonName, instanceId, 'claim'); if (claimCallback) { @@ -314,7 +317,7 @@ function virtualBatonf(options) { // Release the baton you hold, or just log that you are not holding it. exports.release = function release(optionalReplacementOnRelease) { debugFlow('baton:', batonName, instanceId, 'release'); - if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. + if (optionalReplacementOnRelease) { // If you want to change. releaseCallback = optionalReplacementOnRelease; } if (acceptedId() !== instanceId) { @@ -323,7 +326,7 @@ function virtualBatonf(options) { } localRelease(); if (!claimCallback) { // No claim set in release callback. - propose(); // We are the distinguished proposer, but we'll pick anyone else interested, else set it to null. + propose(); } return exports; }; @@ -348,7 +351,7 @@ function virtualBatonf(options) { propose(); return exports; } -if (typeof module !== 'undefined') { +if (typeof module !== 'undefined') { // Allow testing in nodejs. module.exports = virtualBatonf; } else { virtualBaton = virtualBatonf; diff --git a/examples/tests/performance/tribbles.js b/examples/tests/performance/tribbles.js index da533f490a..a48ded730d 100644 --- a/examples/tests/performance/tribbles.js +++ b/examples/tests/performance/tribbles.js @@ -14,8 +14,8 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // The _TIMEOUT parameters can be 0 for no activity, and -1 to be active indefinitely. // -var NUMBER_TO_CREATE = 200; -var LIFETIME = 60; // seconds +var NUMBER_TO_CREATE = 1; // FIXME 200; +var LIFETIME = 30; // FIXME 60; // seconds var EDIT_RATE = 60; // hz var EDIT_TIMEOUT = -1; var MOVE_RATE = 1; // hz @@ -68,7 +68,8 @@ Script.setInterval(function () { moveTimeout: MOVE_TIMEOUT, moveRate: MOVE_RATE, editTimeout: EDIT_TIMEOUT, - editRate: EDIT_RATE + editRate: EDIT_RATE, + debug: {flow: true, send: true} }); for (i = 0; (i < numToCreate) && (totalCreated < NUMBER_TO_CREATE); i++) { Entities.addEntity({ diff --git a/tests/mocha/README.md b/tests/mocha/README.md new file mode 100644 index 0000000000..0bda9a20bc --- /dev/null +++ b/tests/mocha/README.md @@ -0,0 +1,5 @@ +mocha tests of javascript code (e.g., from ../../examples/libraries/). +``` +npm install +npm test +``` \ No newline at end of file diff --git a/tests/mocha/package.json b/tests/mocha/package.json new file mode 100644 index 0000000000..0532677f26 --- /dev/null +++ b/tests/mocha/package.json @@ -0,0 +1,11 @@ +{ + "name": "HighFidelityTests", + "version": "1.0.0", + "scripts": { + "test": "mocha" + }, + "license": "Apache 2.0", + "devDependencies": { + "mocha": "^2.2.1" + } +} diff --git a/tests/mocha/test/testVirtualBaton.js b/tests/mocha/test/testVirtualBaton.js new file mode 100644 index 0000000000..c661bfeffc --- /dev/null +++ b/tests/mocha/test/testVirtualBaton.js @@ -0,0 +1,220 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true */ +var assert = require('assert'); +var mocha = require('mocha'), describe = mocha.describe, it = mocha.it, after = mocha.after; +var virtualBaton = require('../../../examples/libraries/virtualBaton.js'); + +describe('temp', function () { + var messageCount = 0, testStart = Date.now(); + function makeMessager(nodes, me, mode) { // shim for High Fidelity Message system + function noopSend(channel, string, source) { } + function hasChannel(node, channel) { return -1 !== node.subscribed.indexOf(channel); } + function sendSync(channel, message, nodes, skip) { + nodes.forEach(function (node) { + if (!hasChannel(node, channel) || (node === skip)) { return; } + node.sender(channel, message, me.name); + }); + } + nodes.forEach(function (node) { + node.sender = node.sender || noopSend; + node.subscribed = node.subscribed || []; + }); + return { + subscriberCount: function () { + var c = 0; + nodes.forEach(function (n) { if (n.subscribed.length) { c++; } }); + return c; + }, + subscribe: function (channel) { + me.subscribed.push(channel); + }, + unsubscribe: function (channel) { + me.subscribed.splice(me.subscribed.indexOf(channel), 1); + }, + sendMessage: function (channel, message) { + if ((mode === 'immediate2Me') && hasChannel(me, channel)) { me.sender(channel, message, me.name); } + if (mode === 'immediate') { + sendSync(channel, message, nodes, null); + } else { + process.nextTick(function () { + sendSync(channel, message, nodes, (mode === 'immediate2Me') ? me : null); + }); + } + }, + messageReceived: { + connect: function (f) { + me.sender = function (c, m, i) { messageCount++; f(c, m, i); }; + }, + disconnect: function () { + me.sender = noopSend; + } + } + }; + } + var debug = {}; //{flow: true, send: false, receive: false}; + function makeBaton(testKey, nodes, node, debug, mode, optimize) { + debug = debug || {}; + var baton = virtualBaton({ + batonName: testKey, + debugSend: debug.send, + debugReceive: debug.receive, + debugFlow: debug.flow, + useOptimizations: optimize, + connectionTest: function (id) { return baton.validId(id); }, + globals: { + Messages: makeMessager(nodes, node, mode), + MyAvatar: {sessionUUID: node.name}, + Script: { + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + clearInterval: clearInterval + }, + AvatarList: { + getAvatar: function (id) { return {sessionUUID: id}; } + }, + Entities: {getEntityProperties: function () { }}, + print: console.log + } + }); + return baton; + } + function noRelease(batonName) { assert.ok(!batonName, "should not release"); } + function defineABunch(mode, optimize) { + function makeKey(prefix) { return prefix + mode + (optimize ? '-opt' : ''); } + var testKeys = makeKey('single-'); + it(testKeys, function (done) { + var nodes = [{name: 'a'}]; + var a = makeBaton(testKeys, nodes, nodes[0], debug, mode).claim(function (key) { + console.log('claimed a'); + assert.equal(testKeys, key); + a.unload(); + done(); + }, noRelease); + }); + var testKeydp = makeKey('dual-parallel-'); + it(testKeydp, function (done) { + this.timeout(10000); + var nodes = [{name: 'ap'}, {name: 'bp'}]; + var a = makeBaton(testKeydp, nodes, nodes[0], debug, mode, optimize), + b = makeBaton(testKeydp, nodes, nodes[1], debug, mode, optimize); + function accepted(key) { // Under some circumstances of network timing, either a or b can win. + console.log('claimed ap'); + assert.equal(testKeydp, key); + done(); + } + a.claim(accepted, noRelease); + b.claim(accepted, noRelease); + }); + var testKeyds = makeKey('dual-serial-'); + it(testKeyds, function (done) { + var nodes = [{name: 'as'}, {name: 'bs'}], + gotA = false, + gotB = false; + makeBaton(testKeyds, nodes, nodes[0], debug, mode, optimize).claim(function (key) { + console.log('claimed as', key); + assert.ok(!gotA, "should not get A after B"); + gotA = true; + done(); + }, noRelease); + setTimeout(function () { + makeBaton(testKeyds, nodes, nodes[1], debug, mode, optimize).claim(function (key) { + console.log('claimed bs', key); + assert.ok(!gotB, "should not get B after A"); + gotB = true; + done(); + }, noRelease); + }, 500); + }); + var testKeydsl = makeKey('dual-serial-long-'); + it(testKeydsl, function (done) { + this.timeout(5000); + var nodes = [{name: 'al'}, {name: 'bl'}], + gotA = false, + gotB = false, + releaseA = false; + makeBaton(testKeydsl, nodes, nodes[0], debug, mode, optimize).claim(function (key) { + console.log('claimed al', key); + assert.ok(!gotB, "should not get A after B"); + gotA = true; + if (!gotB) { done(); } + }, function () { + assert.ok(gotA, "Should claim it first"); + releaseA = true; + if (gotB) { done(); } + }); + setTimeout(function () { + makeBaton(testKeydsl, nodes, nodes[1], debug, mode, optimize).claim(function (key) { + console.log('claimed bl', key); + gotB = true; + if (releaseA) { done(); } + }, noRelease); + }, 3000); + }); + var testKeydsr = makeKey('dual-serial-with-release-'); + it(testKeydsr, function (done) { + this.timeout(5000); + var nodes = [{name: 'asr'}, {name: 'bsr'}], + gotClaimA = false, + gotReleaseA = false, + a = makeBaton(testKeydsr, nodes, nodes[0], debug, mode, optimize), + b = makeBaton(testKeydsr, nodes, nodes[1], debug, mode, optimize); + a.claim(function (key) { + console.log('claimed asr'); + assert.equal(testKeydsr, key); + gotClaimA = true; + b.claim(function (key) { + console.log('claimed bsr'); + assert.equal(testKeydsr, key); + assert.ok(gotReleaseA); + done(); + }, noRelease); + a.release(); + }, function (key) { + console.log('released asr'); + assert.equal(testKeydsr, key); + assert.ok(gotClaimA); + gotReleaseA = true; + }); + }); + var testKeydpr = makeKey('dual-parallel-with-release-'); + it(testKeydpr, function (done) { + this.timeout(5000); + var nodes = [{name: 'ar'}, {name: 'br'}]; + var a = makeBaton(testKeydpr, nodes, nodes[0], debug, mode, optimize), + b = makeBaton(testKeydpr, nodes, nodes[1], debug, mode, optimize), + gotClaimA = false, + gotReleaseA = false, + gotClaimB = false; + a.claim(function (key) { + console.log('claimed ar'); + assert.equal(testKeydpr, key); + gotClaimA = true; + assert.ok(!gotClaimB, "if b claimed, should not get a"); + a.release(); + }, function (key) { + console.log('released ar'); + assert.equal(testKeydpr, key); + assert.ok(gotClaimA); + gotReleaseA = true; + }); + b.claim(function (key) { + console.log('claimed br', gotClaimA ? 'with' : 'without', 'ar first'); + assert.equal(testKeydpr, key); + gotClaimB = true; + assert.ok(!gotClaimA || gotReleaseA); + done(); + }, noRelease); + }); + } + function defineAllModeTests(optimize) { + defineABunch('delayed', optimize); + defineABunch('immediate2Me', optimize); + defineABunch('immediate', optimize); + } + defineAllModeTests(true); + defineAllModeTests(false); + after(function () { + console.log(messageCount, 'messages sent over', (Date.now() - testStart), 'ms.'); + }); +}); From 61e521bc63b2ebfc4ad24bcf328e1cbf223d21bc Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Mon, 8 Feb 2016 09:33:32 -0800 Subject: [PATCH 18/24] Fix build (and remove unneeded test file). --- examples/tests/testBaton.js | 35 ----------------------------------- tests/CMakeLists.txt | 2 +- 2 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 examples/tests/testBaton.js diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js deleted file mode 100644 index 74fa0d39e4..0000000000 --- a/examples/tests/testBaton.js +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; -/*jslint nomen: true, plusplus: true, vars: true*/ -var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; -// -// Created by Howard Stearns -// Copyright 2016 High Fidelity, Inc. -// -// Distributed under the Apache License, Version 2.0. -// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html -// -// test libraries/virtualBaton.js -// All participants should run the test script. - - -Script.include("../libraries/virtualBaton.31.js"); -var TICKER_INTERVAL = 1000; // ms -var baton = virtualBaton({key: 'io.highfidelity.testBaton', debugSend: true, debugFlow: true, debugReceive: true}); -var ticker, countDown; - -// Tick every TICKER_INTERVAL. -function gotBaton(key) { - print("gotBaton", key); - countDown = 20; - ticker = Script.setInterval(function () { - print("tick"); - }, 1000); -} -// If we've lost the baton (e.g., to network problems), stop ticking -// but ask for the baton back (waiting indefinitely to get it). -function lostBaton(key) { - print("lostBaton", key); - Script.clearInterval(ticker); - baton.claim(gotBaton, lostBaton); -} -baton.claim(gotBaton, lostBaton); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ffdf4b7602..a8b0727e3d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,7 +4,7 @@ enable_testing() # add the test directories file(GLOB TEST_SUBDIRS RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/*") -list(REMOVE_ITEM TEST_SUBDIRS "CMakeFiles") +list(REMOVE_ITEM TEST_SUBDIRS "CMakeFiles" "mocha") foreach(DIR ${TEST_SUBDIRS}) if(IS_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/${DIR}") set(TEST_PROJ_NAME ${DIR}) From 9670cf3fe32baf9da0e992deafc48a98aa94b2af Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Mon, 8 Feb 2016 09:37:56 -0800 Subject: [PATCH 19/24] Reference correct filename. --- examples/entityScripts/tribble.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/entityScripts/tribble.js b/examples/entityScripts/tribble.js index a5aaf4feb6..e34213cfa3 100644 --- a/examples/entityScripts/tribble.js +++ b/examples/entityScripts/tribble.js @@ -1,6 +1,6 @@ (function () { // See tests/performance/tribbles.js - Script.include("../libraries/virtualBaton.42.js"); + Script.include("../libraries/virtualBaton.js"); var dimensions, oldColor, entityID, editRate = 60, moveRate = 1, From d174e863472e3241dce57a092b1ef25840846a77 Mon Sep 17 00:00:00 2001 From: howard-stearns Date: Mon, 8 Feb 2016 16:45:25 -0800 Subject: [PATCH 20/24] fix batonName for entity script case --- examples/entityScripts/tribble.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/entityScripts/tribble.js b/examples/entityScripts/tribble.js index e34213cfa3..3afdcc43e4 100644 --- a/examples/entityScripts/tribble.js +++ b/examples/entityScripts/tribble.js @@ -65,7 +65,7 @@ oldColor = properties.color; dimensions = Vec3.multiply(scale, properties.dimensions); baton = virtualBaton({ - batonName: 'io.highfidelity.tribble', + batonName: 'io.highfidelity.tribble:' + entityID, // One winner for each entity debugFlow: debug.flow, debugSend: debug.send, debugReceive: debug.receive From cecf2fb611c474df895d413ab5a7505200c419f4 Mon Sep 17 00:00:00 2001 From: howard-stearns Date: Mon, 8 Feb 2016 17:00:29 -0800 Subject: [PATCH 21/24] Up the number. --- examples/tests/performance/tribbles.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/tests/performance/tribbles.js b/examples/tests/performance/tribbles.js index a48ded730d..f4eef2ff1a 100644 --- a/examples/tests/performance/tribbles.js +++ b/examples/tests/performance/tribbles.js @@ -14,8 +14,8 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // The _TIMEOUT parameters can be 0 for no activity, and -1 to be active indefinitely. // -var NUMBER_TO_CREATE = 1; // FIXME 200; -var LIFETIME = 30; // FIXME 60; // seconds +var NUMBER_TO_CREATE = 100; +var LIFETIME = 120; // seconds var EDIT_RATE = 60; // hz var EDIT_TIMEOUT = -1; var MOVE_RATE = 1; // hz @@ -69,7 +69,7 @@ Script.setInterval(function () { moveRate: MOVE_RATE, editTimeout: EDIT_TIMEOUT, editRate: EDIT_RATE, - debug: {flow: true, send: true} + debug: {flow: false, send: false, receive: false} }); for (i = 0; (i < numToCreate) && (totalCreated < NUMBER_TO_CREATE); i++) { Entities.addEntity({ From 4241f4b30537dc48893298fdbee1ce7fdabe756a Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Wed, 10 Feb 2016 12:03:07 -0800 Subject: [PATCH 22/24] Convert all simple one-liners containing ') { ' to instead take up three lines. --- examples/entityScripts/tribble.js | 20 +++++++--- examples/libraries/virtualBaton.js | 40 +++++++++++++++----- tests/mocha/test/testVirtualBaton.js | 56 +++++++++++++++++++++------- 3 files changed, 87 insertions(+), 29 deletions(-) diff --git a/examples/entityScripts/tribble.js b/examples/entityScripts/tribble.js index 3afdcc43e4..22990af1d1 100644 --- a/examples/entityScripts/tribble.js +++ b/examples/entityScripts/tribble.js @@ -29,13 +29,19 @@ accumulated = 0; } } - function randomCentered() { return Math.random() - 0.5; } - function randomVector() { return {x: randomCentered() * dimensions.x, y: randomCentered() * dimensions.y, z: randomCentered() * dimensions.z}; } + function randomCentered() { + return Math.random() - 0.5; + } + function randomVector() { + return {x: randomCentered() * dimensions.x, y: randomCentered() * dimensions.y, z: randomCentered() * dimensions.z}; + } function move() { var newData = {velocity: Vec3.sum({x: 0, y: 1, z: 0}, randomVector()), angularVelocity: Vec3.multiply(Math.PI, randomVector())}; var nextChange = Math.ceil(Math.random() * 2000 / moveRate); Entities.editEntity(entityID, newData); - if (!shutdown) { Script.setTimeout(move, nextChange); } + if (!shutdown) { + Script.setTimeout(move, nextChange); + } } function startUpdate() { print('startUpdate', entityID); @@ -44,7 +50,9 @@ } function stopUpdate() { print('stopUpdate', entityID, hasUpdate); - if (!hasUpdate) { return; } + if (!hasUpdate) { + return; + } hasUpdate = false; Script.update.disconnect(update); } @@ -79,7 +87,9 @@ if (moveTimeout) { Script.setTimeout(move, 1000); if (moveTimeout > 0) { - Script.setTimeout(function () { shutdown = true; }, moveTimeout * 1000); + Script.setTimeout(function () { + shutdown = true; + }, moveTimeout * 1000); } } }; diff --git a/examples/libraries/virtualBaton.js b/examples/libraries/virtualBaton.js index b1f813f764..dad31a7b1e 100644 --- a/examples/libraries/virtualBaton.js +++ b/examples/libraries/virtualBaton.js @@ -60,7 +60,9 @@ function virtualBatonf(options) { // order) of both.) connectionTest = options.connectionTest || function connectionTest(id) { var idLength = 38; - if (id.length === idLength) { return exports.validId(id); } + if (id.length === idLength) { + return exports.validId(id); + } return (id.length === 2 * idLength) && exports.validId(id.slice(0, idLength)) && exports.validId(id.slice(idLength)); }; @@ -70,7 +72,9 @@ function virtualBatonf(options) { // Truthy if id exists as either a connected avatar or valid entity. exports.validId = function validId(id) { var avatar = avatarList.getAvatar(id); - if (avatar && (avatar.sessionUUID === id)) { return true; } + if (avatar && (avatar.sessionUUID === id)) { + return true; + } var properties = entities.getEntityProperties(id, ['type']); return properties && properties.type; }; @@ -80,13 +84,19 @@ function virtualBatonf(options) { log.apply(null, [].map.call(arguments, JSON.stringify)); } function debugFlow() { - if (options.debugFlow) { debug.apply(null, arguments); } + if (options.debugFlow) { + debug.apply(null, arguments); + } } function debugSend(destination, operation, data) { - if (options.debugSend) { debug('baton:', batonName, instanceId, 's=>', destination, operation, data); } + if (options.debugSend) { + debug('baton:', batonName, instanceId, 's=>', destination, operation, data); + } } function debugReceive(senderID, operation, data) { // senderID is client sessionUUID -- not necessarily instanceID! - if (options.debugReceive) { debug('baton:', batonName, senderID, '=>r', instanceId, operation, data); } + if (options.debugReceive) { + debug('baton:', batonName, senderID, '=>r', instanceId, operation, data); + } } // Messages: Just synactic sugar for hooking things up to Messages system. @@ -95,7 +105,9 @@ function virtualBatonf(options) { var channelKey = "io.highfidelity.virtualBaton:" + batonName, subchannelHandlers = {}, // Message channel string => {receiver, op} subchannelKeys = {}; // operation => Message channel string - function subchannelKey(operation) { return channelKey + ':' + operation; } + function subchannelKey(operation) { + return channelKey + ':' + operation; + } function receive(operation, handler) { // Record a handler for an operation on our channelKey var subKey = subchannelKey(operation); subchannelHandlers[subKey] = {receiver: handler, op: operation}; @@ -116,7 +128,9 @@ function virtualBatonf(options) { } function messageHandler(channel, messageString, senderID) { var handler = subchannelHandlers[channel]; - if (!handler) { return; } + if (!handler) { + return; + } var data = JSON.parse(messageString); debugReceive(senderID, handler.op, data); handler.receiver(data); @@ -215,7 +229,9 @@ function virtualBatonf(options) { }); // Paxos Acceptor behavior var bestProposal = {number: 0}, accepted = {}; - function acceptedId() { return accepted && accepted.winner; } + function acceptedId() { + return accepted && accepted.winner; + } receive('prepare!', function (data) { var response = {proposalNumber: data.number, proposerId: data.proposerId}; if (betterNumber(data, bestProposal)) { @@ -256,7 +272,9 @@ function virtualBatonf(options) { function localRelease() { var callback = releaseCallback; debugFlow('baton:', batonName, 'localRelease', 'callback:', !!releaseCallback); - if (!releaseCallback) { return; } // Already released, but we might still receive a stale message. That's ok. + if (!releaseCallback) { + return; + } // Already released, but we might still receive a stale message. That's ok. releaseCallback = undefined; callback(batonName); // Pass batonName so that clients may use the same handler for different batons. } @@ -340,7 +358,9 @@ function virtualBatonf(options) { exports.unload = function unload() { // Disconnect from everything. messages.messageReceived.disconnect(messageHandler); timers.clearInterval(exports.recheckWatchdog); - if (electionWatchdog) { timers.clearTimeout(electionWatchdog); } + if (electionWatchdog) { + timers.clearTimeout(electionWatchdog); + } electionWatchdog = claimCallback = releaseCallback = null; Object.keys(subchannelHandlers).forEach(messages.unsubscribe); debugFlow('baton:', batonName, instanceId, 'unload'); diff --git a/tests/mocha/test/testVirtualBaton.js b/tests/mocha/test/testVirtualBaton.js index c661bfeffc..2a4edb4d5d 100644 --- a/tests/mocha/test/testVirtualBaton.js +++ b/tests/mocha/test/testVirtualBaton.js @@ -7,11 +7,16 @@ var virtualBaton = require('../../../examples/libraries/virtualBaton.js'); describe('temp', function () { var messageCount = 0, testStart = Date.now(); function makeMessager(nodes, me, mode) { // shim for High Fidelity Message system - function noopSend(channel, string, source) { } - function hasChannel(node, channel) { return -1 !== node.subscribed.indexOf(channel); } + function noopSend(channel, string, source) { + } + function hasChannel(node, channel) { + return -1 !== node.subscribed.indexOf(channel); + } function sendSync(channel, message, nodes, skip) { nodes.forEach(function (node) { - if (!hasChannel(node, channel) || (node === skip)) { return; } + if (!hasChannel(node, channel) || (node === skip)) { + return; + } node.sender(channel, message, me.name); }); } @@ -22,7 +27,11 @@ describe('temp', function () { return { subscriberCount: function () { var c = 0; - nodes.forEach(function (n) { if (n.subscribed.length) { c++; } }); + nodes.forEach(function (n) { + if (n.subscribed.length) { + c++; + } + }); return c; }, subscribe: function (channel) { @@ -32,7 +41,9 @@ describe('temp', function () { me.subscribed.splice(me.subscribed.indexOf(channel), 1); }, sendMessage: function (channel, message) { - if ((mode === 'immediate2Me') && hasChannel(me, channel)) { me.sender(channel, message, me.name); } + if ((mode === 'immediate2Me') && hasChannel(me, channel)) { + me.sender(channel, message, me.name); + } if (mode === 'immediate') { sendSync(channel, message, nodes, null); } else { @@ -43,7 +54,9 @@ describe('temp', function () { }, messageReceived: { connect: function (f) { - me.sender = function (c, m, i) { messageCount++; f(c, m, i); }; + me.sender = function (c, m, i) { + messageCount++; f(c, m, i); + }; }, disconnect: function () { me.sender = noopSend; @@ -60,7 +73,9 @@ describe('temp', function () { debugReceive: debug.receive, debugFlow: debug.flow, useOptimizations: optimize, - connectionTest: function (id) { return baton.validId(id); }, + connectionTest: function (id) { + return baton.validId(id); + }, globals: { Messages: makeMessager(nodes, node, mode), MyAvatar: {sessionUUID: node.name}, @@ -71,17 +86,24 @@ describe('temp', function () { clearInterval: clearInterval }, AvatarList: { - getAvatar: function (id) { return {sessionUUID: id}; } + getAvatar: function (id) { + return {sessionUUID: id}; + } }, - Entities: {getEntityProperties: function () { }}, + Entities: {getEntityProperties: function () { + }}, print: console.log } }); return baton; } - function noRelease(batonName) { assert.ok(!batonName, "should not release"); } + function noRelease(batonName) { + assert.ok(!batonName, "should not release"); + } function defineABunch(mode, optimize) { - function makeKey(prefix) { return prefix + mode + (optimize ? '-opt' : ''); } + function makeKey(prefix) { + return prefix + mode + (optimize ? '-opt' : ''); + } var testKeys = makeKey('single-'); it(testKeys, function (done) { var nodes = [{name: 'a'}]; @@ -137,17 +159,23 @@ describe('temp', function () { console.log('claimed al', key); assert.ok(!gotB, "should not get A after B"); gotA = true; - if (!gotB) { done(); } + if (!gotB) { + done(); + } }, function () { assert.ok(gotA, "Should claim it first"); releaseA = true; - if (gotB) { done(); } + if (gotB) { + done(); + } }); setTimeout(function () { makeBaton(testKeydsl, nodes, nodes[1], debug, mode, optimize).claim(function (key) { console.log('claimed bl', key); gotB = true; - if (releaseA) { done(); } + if (releaseA) { + done(); + } }, noRelease); }, 3000); }); From 31d72ffb8d86ba6b5f2971e3c121529d4b9a2fce Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Wed, 10 Feb 2016 12:17:58 -0800 Subject: [PATCH 23/24] 2 is magic. --- examples/libraries/virtualBaton.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/libraries/virtualBaton.js b/examples/libraries/virtualBaton.js index dad31a7b1e..13d9abe9e7 100644 --- a/examples/libraries/virtualBaton.js +++ b/examples/libraries/virtualBaton.js @@ -29,7 +29,10 @@ function virtualBatonf(options) { // Answer averages (number +/- variability). Avoids having everyone act in lockstep. function randomize(number, variability) { var allowedDeviation = number * variability; - return number - allowedDeviation + (Math.random() * 2 * allowedDeviation); + var theNumberThatIsTwice = 2; + // random() is (0, 1], averages 0.5. The average of twice that is 1. + var randomDeviation = Math.random() * theNumberThatIsTwice * allowedDeviation; + return number - allowedDeviation + randomDeviation; } // Allow testing outside in a harness outside of High Fidelity. // See sourceCodeSandbox/tests/mocha/test/testVirtualBaton.js From 989192e9fd1c4bd9d0875968b0e34408a4297d4c Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Wed, 10 Feb 2016 12:53:05 -0800 Subject: [PATCH 24/24] Make randomize clearer. --- examples/libraries/virtualBaton.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/libraries/virtualBaton.js b/examples/libraries/virtualBaton.js index 13d9abe9e7..63f96a5c1e 100644 --- a/examples/libraries/virtualBaton.js +++ b/examples/libraries/virtualBaton.js @@ -28,11 +28,11 @@ function virtualBatonf(options) { // Answer averages (number +/- variability). Avoids having everyone act in lockstep. function randomize(number, variability) { - var allowedDeviation = number * variability; - var theNumberThatIsTwice = 2; - // random() is (0, 1], averages 0.5. The average of twice that is 1. - var randomDeviation = Math.random() * theNumberThatIsTwice * allowedDeviation; - return number - allowedDeviation + randomDeviation; + var allowedDeviation = number * variability; // one side of the deviation range + var allowedDeviationRange = allowedDeviation * 2; // total range for +/- deviation + var randomDeviation = Math.random() * allowedDeviationRange; + var result = number - allowedDeviation + randomDeviation; + return result; } // Allow testing outside in a harness outside of High Fidelity. // See sourceCodeSandbox/tests/mocha/test/testVirtualBaton.js