3 * Simple lock server daemon that accepts lock/unlock requests.
5 * This code should not require MediaWiki setup or PHP files.
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
23 * @ingroup LockManager Maintenance
26 if ( php_sapi_name() !== 'cli' ) {
27 die( "This is not a valid entry point.\n" );
29 error_reporting( E_ALL
);
33 LockServerDaemon
::init(
35 'address:', 'port:', 'authKey:',
36 'lockTimeout::', 'maxClients::', 'maxBacklog::', 'maxLocks::',
41 * Simple lock server daemon that accepts lock/unlock requests
43 * @ingroup LockManager Maintenance
45 class LockServerDaemon
{
47 protected $sock; // socket to listen/accept on
49 protected $sessions = array(); // (session => resource)
51 protected $deadSessions = array(); // (session => UNIX timestamp)
53 /** @var LockHolder */
54 protected $lockHolder;
56 protected $address; // string IP address
57 protected $port; // integer
58 protected $authKey; // string key
59 protected $lockTimeout; // integer number of seconds
60 protected $maxBacklog; // integer
61 protected $maxClients; // integer
63 protected $startTime; // integer UNIX timestamp
64 protected $ticks = 0; // integer counter
66 /* @var LockServerDaemon */
67 protected static $instance = null;
70 * @params $config Array
71 * @param array $config
73 * @return LockServerDaemon
75 public static function init( array $config ) {
76 if ( self
::$instance ) {
77 throw new Exception( 'LockServer already initialized.' );
79 foreach ( array( 'address', 'port', 'authKey' ) as $par ) {
80 if ( !isset( $config[$par] ) ) {
81 die( "Usage: php LockServerDaemon.php " .
82 "--address <address> --port <port> --authkey <key> " .
83 "[--lockTimeout <seconds>] " .
84 "[--maxLocks <integer>] [--maxClients <integer>] [--maxBacklog <integer>]"
88 self
::$instance = new self( $config );
89 return self
::$instance;
93 * @params $config Array
95 protected function __construct( array $config ) {
96 // Required parameters...
97 $this->address
= $config['address'];
98 $this->port
= $config['port'];
99 $this->authKey
= $config['authKey'];
100 // Parameters with defaults...
101 $this->lockTimeout
= isset( $config['lockTimeout'] )
102 ?
(int)$config['lockTimeout']
104 $this->maxClients
= isset( $config['maxClients'] )
105 ?
(int)$config['maxClients']
106 : 1000; // less than default FD_SETSIZE
107 $this->maxBacklog
= isset( $config['maxBacklog'] )
108 ?
(int)$config['maxBacklog']
110 $maxLocks = isset( $config['maxLocks'] )
111 ?
(int)$config['maxLocks']
114 $this->lockHolder
= new LockHolder( $maxLocks );
121 protected function setupServerSocket() {
122 if ( !function_exists( 'socket_create' ) ) {
123 throw new Exception( "PHP sockets extension missing from PHP CLI mode." );
125 $sock = socket_create( AF_INET
, SOCK_STREAM
, SOL_TCP
);
126 if ( $sock === false ) {
127 throw new Exception( "socket_create(): " . socket_strerror( socket_last_error() ) );
129 socket_set_option( $sock, SOL_SOCKET
, SO_REUSEADDR
, 1 ); // bypass 2MLS
130 socket_set_nonblock( $sock ); // don't block on accept()
131 if ( socket_bind( $sock, $this->address
, $this->port
) === false ) {
132 throw new Exception( "socket_bind(): " .
133 socket_strerror( socket_last_error( $sock ) ) );
134 } elseif ( socket_listen( $sock, $this->maxBacklog
) === false ) {
135 throw new Exception( "socket_listen(): " .
136 socket_strerror( socket_last_error( $sock ) ) );
139 $this->startTime
= time();
143 * Entry-point function that listens to the server socket, accepts
144 * new clients, and recieves/responds to requests to lock resources.
146 public function main() {
147 $this->setupServerSocket(); // setup listening socket
148 $socketArray = new SocketArray(); // sockets being serviced
149 $socketArray->addSocket( $this->sock
); // add listening socket
151 list( $read, $write ) = $socketArray->socketsForSelect();
152 if ( socket_select( $read, $write, $except = NULL, NULL ) < 1 ) {
155 // Check if there is a client trying to connect...
156 if ( in_array( $this->sock
, $read ) && $socketArray->size() < $this->maxClients
) {
157 $newSock = socket_accept( $this->sock
);
159 socket_set_option( $newSock, SOL_SOCKET
, SO_KEEPALIVE
, 1 );
160 socket_set_nonblock( $newSock ); // don't block on read()/write()
161 $socketArray->addSocket( $newSock );
164 // Loop through all the clients that have data to read...
165 foreach ( $read as $read_sock ) {
166 if ( $read_sock === $this->sock
) {
167 continue; // skip listening socket
169 // Avoids PHP_NORMAL_READ per https://bugs.php.net/bug.php?id=33471
170 $data = socket_read( $read_sock, 65535 );
171 // Check if the client is disconnected
172 if ( $data === false ||
$data === '' ) {
173 $socketArray->closeSocket( $read_sock );
174 $this->recordDeadSocket( $read_sock ); // remove session
175 // Check if we reached the end of a message
176 } elseif ( substr( $data, -1 ) === "\n" ) {
177 // Newline is the last char (given ping-pong message usage)
178 $cmd = $socketArray->readRcvBuffer( $read_sock ) . $data;
179 // Perform the requested command...
180 $response = $this->doCommand( rtrim( $cmd ), $read_sock );
181 // Send the response to the client...
182 $socketArray->appendSndBuffer( $read_sock, $response . "\n" );
183 // Otherwise, we just have more message data to append
184 } elseif ( !$socketArray->appendRcvBuffer( $read_sock, $data ) ) {
185 $socketArray->closeSocket( $read_sock ); // too big
186 $this->recordDeadSocket( $read_sock ); // remove session
189 // Loop through all the clients that have data to write...
190 foreach ( $write as $write_sock ) {
191 $bytes = socket_write( $write_sock, $socketArray->readSndBuffer( $write_sock ) );
192 // Check if the client is disconnected
193 if ( $bytes === false ) {
194 $socketArray->closeSocket( $write_sock );
195 $this->recordDeadSocket( $write_sock ); // remove session
196 // Otherwise, truncate these bytes from the start of the write buffer
198 $socketArray->consumeSndBuffer( $write_sock, $bytes );
201 // Prune dead locks every few socket events...
202 if ( ++
$this->ticks
>= 9 ) {
204 $this->purgeExpiredLocks();
210 * @param $data string
211 * @param $sourceSock resource
214 protected function doCommand( $data, $sourceSock ) {
215 $cmdArr = $this->getCommand( $data );
216 if ( is_string( $cmdArr ) ) {
217 return $cmdArr; // error
219 list( $function, $session, $type, $resources ) = $cmdArr;
220 // On first command, track the session => sock correspondence
221 if ( !isset( $this->sessions
[$session] ) ) {
222 $this->sessions
[$session] = $sourceSock;
223 unset( $this->deadSessions
[$session] ); // renew if dead
225 if ( $function === 'ACQUIRE' ) {
226 return $this->lockHolder
->lock( $session, $type, $resources );
227 } elseif ( $function === 'RELEASE' ) {
228 return $this->lockHolder
->unlock( $session, $type, $resources );
229 } elseif ( $function === 'RELEASE_ALL' ) {
230 return $this->lockHolder
->release( $session );
231 } elseif ( $function === 'STAT' ) {
232 return $this->stat();
234 return 'INTERNAL_ERROR';
238 * @param $data string
241 protected function getCommand( $data ) {
242 $m = explode( ':', $data ); // <session, key, command, type, values>
243 if ( count( $m ) == 5 ) {
244 list( $session, $key, $command, $type, $values ) = $m;
245 if ( sha1( $session . $command . $type . $values . $this->authKey
) !== $key ) {
247 } elseif ( strlen( $session ) !== 32 ) {
248 return 'BAD_SESSION';
250 $values = explode( '|', $values );
251 if ( $command === 'ACQUIRE' ) {
252 $needsLockArgs = true;
253 } elseif ( $command === 'RELEASE' ) {
254 $needsLockArgs = true;
255 } elseif ( $command === 'RELEASE_ALL' ) {
256 $needsLockArgs = false;
257 } elseif ( $command === 'STAT' ) {
258 $needsLockArgs = false;
260 return 'BAD_COMMAND';
262 if ( $needsLockArgs ) {
263 if ( $type !== 'SH' && $type !== 'EX' ) {
266 foreach ( $values as $value ) {
267 if ( strlen( $value ) !== 31 ) {
272 return array( $command, $session, $type, $values );
278 * Remove a socket's corresponding session from tracking and
279 * store it in the dead session tracking if it still has locks.
281 * @param $socket resource
284 protected function recordDeadSocket( $socket ) {
285 $session = array_search( $socket, $this->sessions
);
286 if ( $session !== false ) {
287 unset( $this->sessions
[$session] );
288 // Record recently killed sessions that still have locks
289 if ( $this->lockHolder
->sessionHasLocks( $session ) ) {
290 $this->deadSessions
[$session] = time();
298 * Clear locks for sessions that have been dead for a while
300 * @return integer Number of sessions purged
302 protected function purgeExpiredLocks() {
305 foreach ( $this->deadSessions
as $session => $timestamp ) {
306 if ( ( $now - $timestamp ) > $this->lockTimeout
) {
307 $this->lockHolder
->release( $session );
308 unset( $this->deadSessions
[$session] );
316 * Get the current timestamp and memory usage
320 protected function stat() {
321 return ( time() - $this->startTime
) . ':' . memory_get_usage();
326 * LockServerDaemon helper class that keeps track socket states
330 protected $clients = array(); // array of client sockets
332 protected $rBuffers = array(); // corresponding socket read buffers
334 protected $wBuffers = array(); // corresponding socket write buffers
336 const BUFFER_SIZE
= 65535;
339 * @return Array (list of sockets to read, list of sockets to write)
341 public function socketsForSelect() {
344 foreach ( $this->clients
as $key => $socket ) {
345 if ( $this->wBuffers
[$key] !== '' ) {
346 $wSockets[] = $socket; // wait for writing to unblock
348 $rSockets[] = $socket; // wait for reading to unblock
351 return array( $rSockets, $wSockets );
355 * @return integer Number of client sockets
357 public function size() {
358 return count( $this->clients
);
362 * @param $sock resource
365 public function addSocket( $sock ) {
366 $this->clients
[] = $sock;
367 $this->rBuffers
[] = '';
368 $this->wBuffers
[] = '';
373 * @param $sock resource
376 public function closeSocket( $sock ) {
377 $key = array_search( $sock, $this->clients
);
378 if ( $key === false ) {
381 socket_close( $sock );
382 unset( $this->clients
[$key] );
383 unset( $this->rBuffers
[$key] );
384 unset( $this->wBuffers
[$key] );
389 * @param $sock resource
390 * @param $data string
393 public function appendRcvBuffer( $sock, $data ) {
394 $key = array_search( $sock, $this->clients
);
395 if ( $key === false ) {
397 } elseif ( ( strlen( $this->rBuffers
[$key] ) +
strlen( $data ) ) > self
::BUFFER_SIZE
) {
400 $this->rBuffers
[$key] .= $data;
405 * @param $sock resource
406 * @return string|bool
408 public function readRcvBuffer( $sock ) {
409 $key = array_search( $sock, $this->clients
);
410 if ( $key === false ) {
413 $data = $this->rBuffers
[$key];
414 $this->rBuffers
[$key] = ''; // consume data
419 * @param $sock resource
420 * @param $data string
423 public function appendSndBuffer( $sock, $data ) {
424 $key = array_search( $sock, $this->clients
);
425 if ( $key === false ) {
427 } elseif ( ( strlen( $this->wBuffers
[$key] ) +
strlen( $data ) ) > self
::BUFFER_SIZE
) {
430 $this->wBuffers
[$key] .= $data;
435 * @param $sock resource
438 public function readSndBuffer( $sock ) {
439 $key = array_search( $sock, $this->clients
);
440 if ( $key === false ) {
443 return $this->wBuffers
[$key];
447 * @param $sock resource
448 * @param $bytes integer
451 public function consumeSndBuffer( $sock, $bytes ) {
452 $key = array_search( $sock, $this->clients
);
453 if ( $key === false ) {
456 $this->wBuffers
[$key] = (string)substr( $this->wBuffers
[$key], $bytes );
462 * LockServerDaemon helper class that keeps track of the locks
466 protected $shLocks = array(); // (key => session => 1)
468 protected $exLocks = array(); // (key => session)
471 protected $sessionIndexSh = array(); // (session => key => 1)
473 protected $sessionIndexEx = array(); // (session => key => 1)
474 protected $lockCount = 0; // integer
476 protected $maxLocks; // integer
479 * @params $maxLocks integer Maximum number of locks to allow
481 public function __construct( $maxLocks ) {
482 $this->maxLocks
= $maxLocks;
486 * @param $session string
489 public function sessionHasLocks( $session ) {
490 return isset( $this->sessionIndexSh
[$session] )
491 ||
isset( $this->sessionIndexEx
[$session] );
495 * @param $session string
496 * @param $type string
500 public function lock( $session, $type, array $keys ) {
501 if ( ( $this->lockCount +
count( $keys ) ) > $this->maxLocks
) {
502 return 'TOO_MANY_LOCKS';
504 if ( $type === 'SH' ) {
505 // Check if any keys are already write-locked...
506 foreach ( $keys as $key ) {
507 if ( isset( $this->exLocks
[$key] ) && $this->exLocks
[$key] !== $session ) {
508 return 'CANT_ACQUIRE';
511 // Acquire the read-locks...
512 foreach ( $keys as $key ) {
513 $this->set_sh_lock( $key, $session );
516 } elseif ( $type === 'EX' ) {
517 // Check if any keys are already read-locked or write-locked...
518 foreach ( $keys as $key ) {
519 if ( isset( $this->exLocks
[$key] ) && $this->exLocks
[$key] !== $session ) {
520 return 'CANT_ACQUIRE';
522 if ( isset( $this->shLocks
[$key] ) ) {
523 foreach ( $this->shLocks
[$key] as $otherSession => $x ) {
524 if ( $otherSession !== $session ) {
525 return 'CANT_ACQUIRE';
530 // Acquire the write-locks...
531 foreach ( $keys as $key ) {
532 $this->set_ex_lock( $key, $session );
536 return 'INTERNAL_ERROR';
540 * @param $session string
541 * @param $type string
545 public function unlock( $session, $type, array $keys ) {
546 if ( $type === 'SH' ) {
547 foreach ( $keys as $key ) {
548 $this->unset_sh_lock( $key, $session );
551 } elseif ( $type === 'EX' ) {
552 foreach ( $keys as $key ) {
553 $this->unset_ex_lock( $key, $session );
557 return 'INTERNAL_ERROR';
561 * @param $session string
564 public function release( $session ) {
565 if ( isset( $this->sessionIndexSh
[$session] ) ) {
566 foreach ( $this->sessionIndexSh
[$session] as $key => $x ) {
567 $this->unset_sh_lock( $key, $session );
570 if ( isset( $this->sessionIndexEx
[$session] ) ) {
571 foreach ( $this->sessionIndexEx
[$session] as $key => $x ) {
572 $this->unset_ex_lock( $key, $session );
575 return 'RELEASED_ALL';
580 * @param $session string
583 protected function set_sh_lock( $key, $session ) {
584 if ( !isset( $this->shLocks
[$key][$session] ) ) {
585 $this->shLocks
[$key][$session] = 1;
586 $this->sessionIndexSh
[$session][$key] = 1;
587 ++
$this->lockCount
; // we are adding a lock
593 * @param $session string
596 protected function set_ex_lock( $key, $session ) {
597 if ( !isset( $this->exLocks
[$key][$session] ) ) {
598 $this->exLocks
[$key] = $session;
599 $this->sessionIndexEx
[$session][$key] = 1;
600 ++
$this->lockCount
; // we are adding a lock
606 * @param $session string
609 protected function unset_sh_lock( $key, $session ) {
610 if ( isset( $this->shLocks
[$key][$session] ) ) {
611 unset( $this->shLocks
[$key][$session] );
612 if ( !count( $this->shLocks
[$key] ) ) {
613 unset( $this->shLocks
[$key] );
615 unset( $this->sessionIndexSh
[$session][$key] );
616 if ( !count( $this->sessionIndexSh
[$session] ) ) {
617 unset( $this->sessionIndexSh
[$session] );
625 * @param $session string
628 protected function unset_ex_lock( $key, $session ) {
629 if ( isset( $this->exLocks
[$key] ) && $this->exLocks
[$key] === $session ) {
630 unset( $this->exLocks
[$key] );
631 unset( $this->sessionIndexEx
[$session][$key] );
632 if ( !count( $this->sessionIndexEx
[$session] ) ) {
633 unset( $this->sessionIndexEx
[$session] );