From a42ec32c98b6e33d0adef68ec02ca891d5d1658b Mon Sep 17 00:00:00 2001 From: Joshua Spence Date: Thu, 29 May 2014 07:04:12 -0700 Subject: [PATCH] Modify the Aphlict client to use `LocalConnection`. Summary: Ref T4324. Currently, an Aphlict client (with a corresponding connection to the Aphlict Server) is created for every tab that a user has open. This significantly affects the scalability of Aphlict as a service. Instead, we can use `LocalConnection` instances to coordinate the communication of multiple Aphlict clients to the server. Similar functionality existed prior to D2704, but was removed as the author was not able to get this functionality working as intended. It seems that the main issue with the initial attempt was the use of the `setTimeout` function, which seemed to be a blocking call which prevented messages from being received. I have instead used an event-based model using a `Timer` object. Roughly this works as follows: # The first instance will create an `AphlictClient` and an `AphlictMaster`. The `AphlictClient` will register itself with the `AphlictMaster` and will consequently be notified of incoming messages. # The `AphlictClient` is then responsible for pinging the `AphlictMaster` at regular intervals. If the client does not ping the master in a given period of time, the master will assume that the client is dead and will remove the client from the pool. # Similarly, the `AphlictMaster` is required to respond to pings with a "pong" response. The pong response lets the clients know that the `AphlictMaster` is still alive. If the clients do not receive a pong in a given period of time, then the clients will attempt to spawn a new master. Test Plan: I have tested this on our Phabricator install with a few tabs opened and inspecting the console output. I will upload a screencast of my test results. Reviewers: #blessed_reviewers, epriestley Reviewed By: #blessed_reviewers, epriestley Subscribers: epriestley, Korvin Maniphest Tasks: T4324 Differential Revision: https://secure.phabricator.com/D9327 --- .../aphlict/client/build_aphlict_client.sh | 13 +- support/aphlict/client/src/Aphlict.as | 113 ++---------- support/aphlict/client/src/AphlictClient.as | 129 ++++++++++++++ support/aphlict/client/src/AphlictMaster.as | 166 ++++++++++++++++++ webroot/rsrc/swf/aphlict.swf | Bin 7217 -> 9213 bytes 5 files changed, 315 insertions(+), 106 deletions(-) create mode 100644 support/aphlict/client/src/AphlictClient.as create mode 100644 support/aphlict/client/src/AphlictMaster.as diff --git a/support/aphlict/client/build_aphlict_client.sh b/support/aphlict/client/build_aphlict_client.sh index b328873d19..f081c9f0d2 100755 --- a/support/aphlict/client/build_aphlict_client.sh +++ b/support/aphlict/client/build_aphlict_client.sh @@ -9,20 +9,13 @@ if [ -z "$MXMLC" ]; then fi; set -e -set -x -# cp -R $ROOT/externals/vegas/src $BASEDIR/src/vegas - -(cd $BASEDIR && $MXMLC \ - -output aphlict.swf \ +$MXMLC \ + -output=$ROOT/webroot/rsrc/swf/aphlict.swf \ -default-background-color=0x444444 \ -default-size=500,500 \ -warnings=true \ -debug=true \ -source-path=$ROOT/externals/vegas/src \ -static-link-runtime-shared-libraries=true \ - src/Aphlict.as) - -mv $BASEDIR/aphlict.swf $ROOT/webroot/rsrc/swf/aphlict.swf - -# -target-player=10.2.0 \ + $BASEDIR/src/AphlictClient.as diff --git a/support/aphlict/client/src/Aphlict.as b/support/aphlict/client/src/Aphlict.as index 14eb9548db..d61ad04b0a 100644 --- a/support/aphlict/client/src/Aphlict.as +++ b/support/aphlict/client/src/Aphlict.as @@ -1,117 +1,38 @@ package { - import flash.net.*; - import flash.utils.*; - import flash.media.*; - import flash.display.*; - import flash.events.*; + import flash.display.Sprite; import flash.external.ExternalInterface; + import flash.net.LocalConnection; - import vegas.strings.JSON; public class Aphlict extends Sprite { - private var client:String; + /** + * A transport channel used to receive data. + */ + protected var recv:LocalConnection; - private var socket:Socket; - private var readBuffer:ByteArray; + /** + * A transport channel used to send data. + */ + protected var send:LocalConnection; - private var remoteServer:String; - private var remotePort:Number; public function Aphlict() { super(); - ExternalInterface.addCallback('connect', this.externalConnect); - ExternalInterface.call( - 'JX.Stratcom.invoke', - 'aphlict-component-ready', - null, - {}); + this.recv = new LocalConnection(); + this.recv.client = this; + + this.send = new LocalConnection(); } - public function externalConnect(server:String, port:Number):void { - this.externalInvoke('connect'); - - this.remoteServer = server; - this.remotePort = port; - - this.connectToServer(); - } - - - public function connectToServer():void { - var socket:Socket = new Socket(); - - socket.addEventListener(Event.CONNECT, didConnectSocket); - socket.addEventListener(Event.CLOSE, didCloseSocket); - socket.addEventListener(ProgressEvent.SOCKET_DATA, didReceiveSocket); - - socket.addEventListener(IOErrorEvent.IO_ERROR, didIOErrorSocket); - socket.addEventListener( - SecurityErrorEvent.SECURITY_ERROR, - didSecurityErrorSocket); - - socket.connect(this.remoteServer, this.remotePort); - - this.readBuffer = new ByteArray(); - this.socket = socket; - } - - private function didConnectSocket(event:Event):void { - this.externalInvoke('connected'); - } - - private function didCloseSocket(event:Event):void { - this.externalInvoke('close'); - } - - private function didIOErrorSocket(event:IOErrorEvent):void { - this.externalInvoke('error', event.text); - } - - private function didSecurityErrorSocket(event:SecurityErrorEvent):void { - this.externalInvoke('error', event.text); - } - - private function didReceiveSocket(event:Event):void { - var b:ByteArray = this.readBuffer; - this.socket.readBytes(b, b.length); - - do { - b = this.readBuffer; - b.position = 0; - - if (b.length <= 8) { - break; - } - - var msg_len:Number = parseInt(b.readUTFBytes(8), 10); - if (b.length >= msg_len + 8) { - var bytes:String = b.readUTFBytes(msg_len); - var data:Object = vegas.strings.JSON.deserialize(bytes); - var t:ByteArray = new ByteArray(); - t.writeBytes(b, msg_len + 8); - this.readBuffer = t; - - this.receiveMessage(data); - } else { - break; - } - } while (true); - - } - - public function receiveMessage(msg:Object):void { - this.externalInvoke('receive', msg); - } - - public function externalInvoke(type:String, object:Object = null):void { + protected function externalInvoke(type:String, object:Object = null):void { ExternalInterface.call('JX.Aphlict.didReceiveEvent', type, object); } - public function log(message:String):void { - ExternalInterface.call('console.log', message); + protected function log(message:String):void { + this.externalInvoke('log', message); } } diff --git a/support/aphlict/client/src/AphlictClient.as b/support/aphlict/client/src/AphlictClient.as new file mode 100644 index 0000000000..31105c67d1 --- /dev/null +++ b/support/aphlict/client/src/AphlictClient.as @@ -0,0 +1,129 @@ +package { + + import flash.events.TimerEvent; + import flash.external.ExternalInterface; + import flash.utils.Timer; + + + public class AphlictClient extends Aphlict { + + /** + * The connection name for this client. This will be used for the + * @{class:LocalConnection} object. + */ + private var client:String; + + /** + * The expiry timestamp for the @{class:AphlictMaster}. If this time is + * elapsed then the master will be assumed to be dead and another + * @{class:AphlictClient} will create a master. + */ + private var expiry:Number = 0; + + /** + * The interval at which to ping the @{class:AphlictMaster}. + */ + public static const INTERVAL:Number = 3000; + + private var master:AphlictMaster; + private var timer:Timer; + + private var remoteServer:String; + private var remotePort:Number; + + + public function AphlictClient() { + super(); + + ExternalInterface.addCallback('connect', this.externalConnect); + ExternalInterface.call( + 'JX.Stratcom.invoke', + 'aphlict-component-ready', + null, + {}); + } + + public function externalConnect(server:String, port:Number):void { + this.externalInvoke('connect'); + + this.remoteServer = server; + this.remotePort = port; + + this.client = AphlictClient.generateClientId(); + this.recv.connect(this.client); + + this.timer = new Timer(AphlictClient.INTERVAL); + this.timer.addEventListener(TimerEvent.TIMER, this.keepalive); + + this.connectToMaster(); + } + + /** + * Generate a unique identifier that will be used to communicate with the + * @{class:AphlictMaster}. + */ + private static function generateClientId():String { + return 'aphlict_client_' + Math.round(Math.random() * 100000); + } + + /** + * Create a new connection to the @{class:AphlictMaster}. + * + * If there is no current @{class:AphlictMaster} instance, then a new master + * will be created. + */ + private function connectToMaster():void { + this.timer.stop(); + + // Try to become the master. + try { + this.log('Attempting to become the master...'); + this.master = new AphlictMaster(this.remoteServer, this.remotePort); + this.log('I am the master.'); + } catch (x:Error) { + // Couldn't become the master + this.log('Cannot become the master... probably one already exists'); + } + + this.send.send('aphlict_master', 'register', this.client); + this.expiry = new Date().getTime() + (5 * AphlictClient.INTERVAL); + this.log('Registered client ' + this.client); + + this.timer.start(); + } + + /** + * Send a keepalive signal to the @{class:AphlictMaster}. + * + * If the connection to the master has expired (because the master has not + * sent a heartbeat signal), then a new connection to master will be + * created. + */ + private function keepalive(event:TimerEvent):void { + if (new Date().getTime() > this.expiry) { + this.connectToMaster(); + } + + this.send.send('aphlict_master', 'ping', this.client); + } + + /** + * This function is used to receive the heartbeat signal from the + * @{class:AphlictMaster}. + */ + public function pong():void { + this.expiry = new Date().getTime() + (2 * AphlictClient.INTERVAL); + } + + /** + * Receive a message from the Aphlict Server, via the + * @{class:AphlictMaster}. + */ + public function receiveMessage(msg:Object):void { + this.log('Received message.'); + this.externalInvoke('receive', msg); + } + + } + +} diff --git a/support/aphlict/client/src/AphlictMaster.as b/support/aphlict/client/src/AphlictMaster.as new file mode 100644 index 0000000000..1991ab48bd --- /dev/null +++ b/support/aphlict/client/src/AphlictMaster.as @@ -0,0 +1,166 @@ +package { + + import flash.events.Event; + import flash.events.IOErrorEvent; + import flash.events.ProgressEvent; + import flash.events.SecurityErrorEvent; + import flash.events.TimerEvent; + import flash.net.Socket; + import flash.utils.ByteArray; + import flash.utils.Dictionary; + import flash.utils.Timer; + import vegas.strings.JSON; + + + public class AphlictMaster extends Aphlict { + + /** + * The pool of connected clients. + */ + private var clients:Dictionary; + + /** + * A timer used to trigger periodic events. + */ + private var timer:Timer; + + /** + * The interval after which clients will be considered dead and removed + * from the pool. + */ + public static const PURGE_INTERVAL:Number = 3 * AphlictClient.INTERVAL; + + /** + * The hostname for the Aphlict Server. + */ + private var remoteServer:String; + + /** + * The port number for the Aphlict Server. + */ + private var remotePort:Number; + + private var socket:Socket; + private var readBuffer:ByteArray; + + + public function AphlictMaster(server:String, port:Number) { + super(); + + this.remoteServer = server; + this.remotePort = port; + + // Connect to the Aphlict Server. + this.recv.connect('aphlict_master'); + this.connectToServer(); + + this.clients = new Dictionary(); + + // Start a timer and regularly purge dead clients. + this.timer = new Timer(AphlictMaster.PURGE_INTERVAL); + this.timer.addEventListener(TimerEvent.TIMER, this.purgeClients); + this.timer.start(); + } + + /** + * Register a @{class:AphlictClient}. + */ + public function register(client:String):void { + if (!this.clients[client]) { + this.log('Registering client: ' + client); + this.clients[client] = new Date().getTime(); + } + } + + /** + * Purge stale client connections from the client pool. + */ + private function purgeClients(event:TimerEvent):void { + for (var client:String in this.clients) { + var checkin:Number = this.clients[client]; + + if (new Date().getTime() - checkin > AphlictMaster.PURGE_INTERVAL) { + this.log('Purging client: ' + client); + delete this.clients[client]; + } + } + } + + /** + * Clients will regularly "ping" the master to let us know that they are + * still alive. We will "pong" them back to let the client know that the + * master is still alive. + */ + public function ping(client:String):void { + this.clients[client] = new Date().getTime(); + this.send.send(client, 'pong'); + } + + private function connectToServer():void { + var socket:Socket = new Socket(); + + socket.addEventListener(Event.CONNECT, didConnectSocket); + socket.addEventListener(Event.CLOSE, didCloseSocket); + socket.addEventListener(ProgressEvent.SOCKET_DATA, didReceiveSocket); + + socket.addEventListener(IOErrorEvent.IO_ERROR, didIOErrorSocket); + socket.addEventListener( + SecurityErrorEvent.SECURITY_ERROR, + didSecurityErrorSocket); + + socket.connect(this.remoteServer, this.remotePort); + + this.readBuffer = new ByteArray(); + this.socket = socket; + } + + private function didConnectSocket(event:Event):void { + this.externalInvoke('connected'); + } + + private function didCloseSocket(event:Event):void { + this.externalInvoke('close'); + } + + private function didIOErrorSocket(event:IOErrorEvent):void { + this.externalInvoke('error', event.text); + } + + private function didSecurityErrorSocket(event:SecurityErrorEvent):void { + this.externalInvoke('error', event.text); + } + + private function didReceiveSocket(event:Event):void { + var b:ByteArray = this.readBuffer; + this.socket.readBytes(b, b.length); + + do { + b = this.readBuffer; + b.position = 0; + + if (b.length <= 8) { + break; + } + + var msg_len:Number = parseInt(b.readUTFBytes(8), 10); + if (b.length >= msg_len + 8) { + var bytes:String = b.readUTFBytes(msg_len); + var data:Object = vegas.strings.JSON.deserialize(bytes); + var t:ByteArray = new ByteArray(); + t.writeBytes(b, msg_len + 8); + this.readBuffer = t; + + // Send the message to all clients. + for (var client:String in this.clients) { + this.log('Sending message to client: ' + client); + this.send.send(client, 'receiveMessage', data); + } + } else { + break; + } + } while (true); + } + + } + +} diff --git a/webroot/rsrc/swf/aphlict.swf b/webroot/rsrc/swf/aphlict.swf index 4ac315b9de8a6669fe373cfbd9e5449814371dc2..bccd0f0d47070f7c0e31d73d8bc76a773f209dcf 100644 GIT binary patch literal 9213 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 T7qYLRTR(h}dO-O<`+!RI$|eAh literal 7217 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