diff --git a/examples/entityScripts/tribble.js b/examples/entityScripts/tribble.js index 3f84901344..22990af1d1 100644 --- a/examples/entityScripts/tribble.js +++ b/examples/entityScripts/tribble.js @@ -1,5 +1,6 @@ (function () { // See tests/performance/tribbles.js + Script.include("../libraries/virtualBaton.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) { @@ -27,13 +29,37 @@ 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); + 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; @@ -41,26 +67,35 @@ 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:' + entityID, // One winner for each entity + debugFlow: debug.flow, + debugSend: debug.send, + debugReceive: debug.receive + }); 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) { Script.setTimeout(move, 1000); if (moveTimeout > 0) { - Script.setTimeout(function () { shutdown = true; }, moveTimeout * 1000); + Script.setTimeout(function () { + shutdown = true; + }, moveTimeout * 1000); } } }; this.unload = function () { + baton.unload(); shutdown = true; - if (hasUpdate) { Script.update.disconnect(update); } + stopUpdate(); }; }) diff --git a/examples/libraries/virtualBaton.js b/examples/libraries/virtualBaton.js new file mode 100644 index 0000000000..63f96a5c1e --- /dev/null +++ b/examples/libraries/virtualBaton.js @@ -0,0 +1,381 @@ +"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; // 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 + 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', 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 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); + } + 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. + // 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' to 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 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; + } + 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 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) { + // 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); // Note use in nSubcribers. + 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 right now/here, 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 && connectionTest(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 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. + // 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. + } + }); + + // 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 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. + 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) { // If you want to change. + 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(); + } + 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)) { + bestPromise.winner = null; // used if the quorum agrees that old winner is not there + 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') { // 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..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 = 200; -var LIFETIME = 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 @@ -68,7 +68,8 @@ Script.setInterval(function () { moveTimeout: MOVE_TIMEOUT, moveRate: MOVE_RATE, editTimeout: EDIT_TIMEOUT, - editRate: EDIT_RATE + editRate: EDIT_RATE, + debug: {flow: false, send: false, receive: false} }); for (i = 0; (i < numToCreate) && (totalCreated < NUMBER_TO_CREATE); i++) { Entities.addEntity({ 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}) 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..2a4edb4d5d --- /dev/null +++ b/tests/mocha/test/testVirtualBaton.js @@ -0,0 +1,248 @@ +"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.'); + }); +});