Merge "(bug 40353) SpecialDoubleRedirect: Fix for interwiki redirects"
[lhc/web/wiklou.git] / maintenance / locking / LockServerDaemon.php
1 <?php
2 /**
3 * Simple lock server daemon that accepts lock/unlock requests.
4 *
5 * This code should not require MediaWiki setup or PHP files.
6 *
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.
11 *
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.
16 *
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
21 *
22 * @file
23 * @ingroup LockManager Maintenance
24 */
25
26 if ( php_sapi_name() !== 'cli' ) {
27 die( "This is not a valid entry point.\n" );
28 }
29 error_reporting( E_ALL );
30
31 // Run the server...
32 set_time_limit( 0 );
33 LockServerDaemon::init(
34 getopt( '', array(
35 'address:', 'port:', 'authKey:',
36 'lockTimeout::', 'maxClients::', 'maxBacklog::', 'maxLocks::',
37 ) )
38 )->main();
39
40 /**
41 * Simple lock server daemon that accepts lock/unlock requests
42 *
43 * @ingroup LockManager Maintenance
44 */
45 class LockServerDaemon {
46 /** @var resource */
47 protected $sock; // socket to listen/accept on
48 /** @var Array */
49 protected $sessions = array(); // (session => resource)
50 /** @var Array */
51 protected $deadSessions = array(); // (session => UNIX timestamp)
52
53 /** @var LockHolder */
54 protected $lockHolder;
55
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
62
63 protected $startTime; // integer UNIX timestamp
64 protected $ticks = 0; // integer counter
65
66 /* @var LockServerDaemon */
67 protected static $instance = null;
68
69 /**
70 * @params $config Array
71 * @return LockServerDaemon
72 */
73 public static function init( array $config ) {
74 if ( self::$instance ) {
75 throw new Exception( 'LockServer already initialized.' );
76 }
77 foreach ( array( 'address', 'port', 'authKey' ) as $par ) {
78 if ( !isset( $config[$par] ) ) {
79 die( "Usage: php LockServerDaemon.php " .
80 "--address <address> --port <port> --authkey <key> " .
81 "[--lockTimeout <seconds>] " .
82 "[--maxLocks <integer>] [--maxClients <integer>] [--maxBacklog <integer>]"
83 );
84 }
85 }
86 self::$instance = new self( $config );
87 return self::$instance;
88 }
89
90 /**
91 * @params $config Array
92 */
93 protected function __construct( array $config ) {
94 // Required parameters...
95 $this->address = $config['address'];
96 $this->port = $config['port'];
97 $this->authKey = $config['authKey'];
98 // Parameters with defaults...
99 $this->lockTimeout = isset( $config['lockTimeout'] )
100 ? (int)$config['lockTimeout']
101 : 60;
102 $this->maxClients = isset( $config['maxClients'] )
103 ? (int)$config['maxClients']
104 : 1000; // less than default FD_SETSIZE
105 $this->maxBacklog = isset( $config['maxBacklog'] )
106 ? (int)$config['maxBacklog']
107 : 100;
108 $maxLocks = isset( $config['maxLocks'] )
109 ? (int)$config['maxLocks']
110 : 10000;
111
112 $this->lockHolder = new LockHolder( $maxLocks );
113 }
114
115 /**
116 * @return void
117 */
118 protected function setupServerSocket() {
119 if ( !function_exists( 'socket_create' ) ) {
120 throw new Exception( "PHP sockets extension missing from PHP CLI mode." );
121 }
122 $sock = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
123 if ( $sock === false ) {
124 throw new Exception( "socket_create(): " . socket_strerror( socket_last_error() ) );
125 }
126 socket_set_option( $sock, SOL_SOCKET, SO_REUSEADDR, 1 ); // bypass 2MLS
127 socket_set_nonblock( $sock ); // don't block on accept()
128 if ( socket_bind( $sock, $this->address, $this->port ) === false ) {
129 throw new Exception( "socket_bind(): " .
130 socket_strerror( socket_last_error( $sock ) ) );
131 } elseif ( socket_listen( $sock, $this->maxBacklog ) === false ) {
132 throw new Exception( "socket_listen(): " .
133 socket_strerror( socket_last_error( $sock ) ) );
134 }
135 $this->sock = $sock;
136 $this->startTime = time();
137 }
138
139 /**
140 * Entry-point function that listens to the server socket, accepts
141 * new clients, and recieves/responds to requests to lock resources.
142 */
143 public function main() {
144 $this->setupServerSocket(); // setup listening socket
145 $socketArray = new SocketArray(); // sockets being serviced
146 $socketArray->addSocket( $this->sock ); // add listening socket
147 do {
148 list( $read, $write ) = $socketArray->socketsForSelect();
149 if ( socket_select( $read, $write, $except = NULL, NULL ) < 1 ) {
150 continue; // wait
151 }
152 // Check if there is a client trying to connect...
153 if ( in_array( $this->sock, $read ) && $socketArray->size() < $this->maxClients ) {
154 $newSock = socket_accept( $this->sock );
155 if ( $newSock ) {
156 socket_set_option( $newSock, SOL_SOCKET, SO_KEEPALIVE, 1 );
157 socket_set_nonblock( $newSock ); // don't block on read()/write()
158 $socketArray->addSocket( $newSock );
159 }
160 }
161 // Loop through all the clients that have data to read...
162 foreach ( $read as $read_sock ) {
163 if ( $read_sock === $this->sock ) {
164 continue; // skip listening socket
165 }
166 // Avoids PHP_NORMAL_READ per https://bugs.php.net/bug.php?id=33471
167 $data = socket_read( $read_sock, 65535 );
168 // Check if the client is disconnected
169 if ( $data === false || $data === '' ) {
170 $socketArray->closeSocket( $read_sock );
171 $this->recordDeadSocket( $read_sock ); // remove session
172 // Check if we reached the end of a message
173 } elseif ( substr( $data, -1 ) === "\n" ) {
174 // Newline is the last char (given ping-pong message usage)
175 $cmd = $socketArray->readRcvBuffer( $read_sock ) . $data;
176 // Perform the requested command...
177 $response = $this->doCommand( rtrim( $cmd ), $read_sock );
178 // Send the response to the client...
179 $socketArray->appendSndBuffer( $read_sock, $response . "\n" );
180 // Otherwise, we just have more message data to append
181 } elseif ( !$socketArray->appendRcvBuffer( $read_sock, $data ) ) {
182 $socketArray->closeSocket( $read_sock ); // too big
183 $this->recordDeadSocket( $read_sock ); // remove session
184 }
185 }
186 // Loop through all the clients that have data to write...
187 foreach ( $write as $write_sock ) {
188 $bytes = socket_write( $write_sock, $socketArray->readSndBuffer( $write_sock ) );
189 // Check if the client is disconnected
190 if ( $bytes === false ) {
191 $socketArray->closeSocket( $write_sock );
192 $this->recordDeadSocket( $write_sock ); // remove session
193 // Otherwise, truncate these bytes from the start of the write buffer
194 } else {
195 $socketArray->consumeSndBuffer( $write_sock, $bytes );
196 }
197 }
198 // Prune dead locks every few socket events...
199 if ( ++$this->ticks >= 9 ) {
200 $this->ticks = 0;
201 $this->purgeExpiredLocks();
202 }
203 } while ( true );
204 }
205
206 /**
207 * @param $data string
208 * @param $sourceSock resource
209 * @return string
210 */
211 protected function doCommand( $data, $sourceSock ) {
212 $cmdArr = $this->getCommand( $data );
213 if ( is_string( $cmdArr ) ) {
214 return $cmdArr; // error
215 }
216 list( $function, $session, $type, $resources ) = $cmdArr;
217 // On first command, track the session => sock correspondence
218 if ( !isset( $this->sessions[$session] ) ) {
219 $this->sessions[$session] = $sourceSock;
220 unset( $this->deadSessions[$session] ); // renew if dead
221 }
222 if ( $function === 'ACQUIRE' ) {
223 return $this->lockHolder->lock( $session, $type, $resources );
224 } elseif ( $function === 'RELEASE' ) {
225 return $this->lockHolder->unlock( $session, $type, $resources );
226 } elseif ( $function === 'RELEASE_ALL' ) {
227 return $this->lockHolder->release( $session );
228 } elseif ( $function === 'STAT' ) {
229 return $this->stat();
230 }
231 return 'INTERNAL_ERROR';
232 }
233
234 /**
235 * @param $data string
236 * @return Array
237 */
238 protected function getCommand( $data ) {
239 $m = explode( ':', $data ); // <session, key, command, type, values>
240 if ( count( $m ) == 5 ) {
241 list( $session, $key, $command, $type, $values ) = $m;
242 if ( sha1( $session . $command . $type . $values . $this->authKey ) !== $key ) {
243 return 'BAD_KEY';
244 } elseif ( strlen( $session ) !== 32 ) {
245 return 'BAD_SESSION';
246 }
247 $values = explode( '|', $values );
248 if ( $command === 'ACQUIRE' ) {
249 $needsLockArgs = true;
250 } elseif ( $command === 'RELEASE' ) {
251 $needsLockArgs = true;
252 } elseif ( $command === 'RELEASE_ALL' ) {
253 $needsLockArgs = false;
254 } elseif ( $command === 'STAT' ) {
255 $needsLockArgs = false;
256 } else {
257 return 'BAD_COMMAND';
258 }
259 if ( $needsLockArgs ) {
260 if ( $type !== 'SH' && $type !== 'EX' ) {
261 return 'BAD_TYPE';
262 }
263 foreach ( $values as $value ) {
264 if ( strlen( $value ) !== 31 ) {
265 return 'BAD_FORMAT';
266 }
267 }
268 }
269 return array( $command, $session, $type, $values );
270 }
271 return 'BAD_FORMAT';
272 }
273
274 /**
275 * Remove a socket's corresponding session from tracking and
276 * store it in the dead session tracking if it still has locks.
277 *
278 * @param $socket resource
279 * @return bool
280 */
281 protected function recordDeadSocket( $socket ) {
282 $session = array_search( $socket, $this->sessions );
283 if ( $session !== false ) {
284 unset( $this->sessions[$session] );
285 // Record recently killed sessions that still have locks
286 if ( $this->lockHolder->sessionHasLocks( $session ) ) {
287 $this->deadSessions[$session] = time();
288 }
289 return true;
290 }
291 return false;
292 }
293
294 /**
295 * Clear locks for sessions that have been dead for a while
296 *
297 * @return integer Number of sessions purged
298 */
299 protected function purgeExpiredLocks() {
300 $count = 0;
301 $now = time();
302 foreach ( $this->deadSessions as $session => $timestamp ) {
303 if ( ( $now - $timestamp ) > $this->lockTimeout ) {
304 $this->lockHolder->release( $session );
305 unset( $this->deadSessions[$session] );
306 ++$count;
307 }
308 }
309 return $count;
310 }
311
312 /**
313 * Get the current timestamp and memory usage
314 *
315 * @return string
316 */
317 protected function stat() {
318 return ( time() - $this->startTime ) . ':' . memory_get_usage();
319 }
320 }
321
322 /**
323 * LockServerDaemon helper class that keeps track socket states
324 */
325 class SocketArray {
326 /* @var Array */
327 protected $clients = array(); // array of client sockets
328 /* @var Array */
329 protected $rBuffers = array(); // corresponding socket read buffers
330 /* @var Array */
331 protected $wBuffers = array(); // corresponding socket write buffers
332
333 const BUFFER_SIZE = 65535;
334
335 /**
336 * @return Array (list of sockets to read, list of sockets to write)
337 */
338 public function socketsForSelect() {
339 $rSockets = array();
340 $wSockets = array();
341 foreach ( $this->clients as $key => $socket ) {
342 if ( $this->wBuffers[$key] !== '' ) {
343 $wSockets[] = $socket; // wait for writing to unblock
344 } else {
345 $rSockets[] = $socket; // wait for reading to unblock
346 }
347 }
348 return array( $rSockets, $wSockets );
349 }
350
351 /**
352 * @return integer Number of client sockets
353 */
354 public function size() {
355 return count( $this->clients );
356 }
357
358 /**
359 * @param $sock resource
360 * @return bool
361 */
362 public function addSocket( $sock ) {
363 $this->clients[] = $sock;
364 $this->rBuffers[] = '';
365 $this->wBuffers[] = '';
366 return true;
367 }
368
369 /**
370 * @param $sock resource
371 * @return bool
372 */
373 public function closeSocket( $sock ) {
374 $key = array_search( $sock, $this->clients );
375 if ( $key === false ) {
376 return false;
377 }
378 socket_close( $sock );
379 unset( $this->clients[$key] );
380 unset( $this->rBuffers[$key] );
381 unset( $this->wBuffers[$key] );
382 return true;
383 }
384
385 /**
386 * @param $sock resource
387 * @param $data string
388 * @return bool
389 */
390 public function appendRcvBuffer( $sock, $data ) {
391 $key = array_search( $sock, $this->clients );
392 if ( $key === false ) {
393 return false;
394 } elseif ( ( strlen( $this->rBuffers[$key] ) + strlen( $data ) ) > self::BUFFER_SIZE ) {
395 return false;
396 }
397 $this->rBuffers[$key] .= $data;
398 return true;
399 }
400
401 /**
402 * @param $sock resource
403 * @return string|bool
404 */
405 public function readRcvBuffer( $sock ) {
406 $key = array_search( $sock, $this->clients );
407 if ( $key === false ) {
408 return false;
409 }
410 $data = $this->rBuffers[$key];
411 $this->rBuffers[$key] = ''; // consume data
412 return $data;
413 }
414
415 /**
416 * @param $sock resource
417 * @param $data string
418 * @return bool
419 */
420 public function appendSndBuffer( $sock, $data ) {
421 $key = array_search( $sock, $this->clients );
422 if ( $key === false ) {
423 return false;
424 } elseif ( ( strlen( $this->wBuffers[$key] ) + strlen( $data ) ) > self::BUFFER_SIZE ) {
425 return false;
426 }
427 $this->wBuffers[$key] .= $data;
428 return true;
429 }
430
431 /**
432 * @param $sock resource
433 * @return bool
434 */
435 public function readSndBuffer( $sock ) {
436 $key = array_search( $sock, $this->clients );
437 if ( $key === false ) {
438 return false;
439 }
440 return $this->wBuffers[$key];
441 }
442
443 /**
444 * @param $sock resource
445 * @param $bytes integer
446 * @return bool
447 */
448 public function consumeSndBuffer( $sock, $bytes ) {
449 $key = array_search( $sock, $this->clients );
450 if ( $key === false ) {
451 return false;
452 }
453 $this->wBuffers[$key] = (string)substr( $this->wBuffers[$key], $bytes );
454 return true;
455 }
456 }
457
458 /**
459 * LockServerDaemon helper class that keeps track of the locks
460 */
461 class LockHolder {
462 /** @var Array */
463 protected $shLocks = array(); // (key => session => 1)
464 /** @var Array */
465 protected $exLocks = array(); // (key => session)
466
467 /** @var Array */
468 protected $sessionIndexSh = array(); // (session => key => 1)
469 /** @var Array */
470 protected $sessionIndexEx = array(); // (session => key => 1)
471 protected $lockCount = 0; // integer
472
473 protected $maxLocks; // integer
474
475 /**
476 * @params $maxLocks integer Maximum number of locks to allow
477 */
478 public function __construct( $maxLocks ) {
479 $this->maxLocks = $maxLocks;
480 }
481
482 /**
483 * @param $session string
484 * @return bool
485 */
486 public function sessionHasLocks( $session ) {
487 return isset( $this->sessionIndexSh[$session] )
488 || isset( $this->sessionIndexEx[$session] );
489 }
490
491 /**
492 * @param $session string
493 * @param $type string
494 * @param $keys Array
495 * @return string
496 */
497 public function lock( $session, $type, array $keys ) {
498 if ( ( $this->lockCount + count( $keys ) ) > $this->maxLocks ) {
499 return 'TOO_MANY_LOCKS';
500 }
501 if ( $type === 'SH' ) {
502 // Check if any keys are already write-locked...
503 foreach ( $keys as $key ) {
504 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] !== $session ) {
505 return 'CANT_ACQUIRE';
506 }
507 }
508 // Acquire the read-locks...
509 foreach ( $keys as $key ) {
510 $this->set_sh_lock( $key, $session );
511 }
512 return 'ACQUIRED';
513 } elseif ( $type === 'EX' ) {
514 // Check if any keys are already read-locked or write-locked...
515 foreach ( $keys as $key ) {
516 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] !== $session ) {
517 return 'CANT_ACQUIRE';
518 }
519 if ( isset( $this->shLocks[$key] ) ) {
520 foreach ( $this->shLocks[$key] as $otherSession => $x ) {
521 if ( $otherSession !== $session ) {
522 return 'CANT_ACQUIRE';
523 }
524 }
525 }
526 }
527 // Acquire the write-locks...
528 foreach ( $keys as $key ) {
529 $this->set_ex_lock( $key, $session );
530 }
531 return 'ACQUIRED';
532 }
533 return 'INTERNAL_ERROR';
534 }
535
536 /**
537 * @param $session string
538 * @param $type string
539 * @param $keys Array
540 * @return string
541 */
542 public function unlock( $session, $type, array $keys ) {
543 if ( $type === 'SH' ) {
544 foreach ( $keys as $key ) {
545 $this->unset_sh_lock( $key, $session );
546 }
547 return 'RELEASED';
548 } elseif ( $type === 'EX' ) {
549 foreach ( $keys as $key ) {
550 $this->unset_ex_lock( $key, $session );
551 }
552 return 'RELEASED';
553 }
554 return 'INTERNAL_ERROR';
555 }
556
557 /**
558 * @param $session string
559 * @return string
560 */
561 public function release( $session ) {
562 if ( isset( $this->sessionIndexSh[$session] ) ) {
563 foreach ( $this->sessionIndexSh[$session] as $key => $x ) {
564 $this->unset_sh_lock( $key, $session );
565 }
566 }
567 if ( isset( $this->sessionIndexEx[$session] ) ) {
568 foreach ( $this->sessionIndexEx[$session] as $key => $x ) {
569 $this->unset_ex_lock( $key, $session );
570 }
571 }
572 return 'RELEASED_ALL';
573 }
574
575 /**
576 * @param $key string
577 * @param $session string
578 * @return void
579 */
580 protected function set_sh_lock( $key, $session ) {
581 if ( !isset( $this->shLocks[$key][$session] ) ) {
582 $this->shLocks[$key][$session] = 1;
583 $this->sessionIndexSh[$session][$key] = 1;
584 ++$this->lockCount; // we are adding a lock
585 }
586 }
587
588 /**
589 * @param $key string
590 * @param $session string
591 * @return void
592 */
593 protected function set_ex_lock( $key, $session ) {
594 if ( !isset( $this->exLocks[$key][$session] ) ) {
595 $this->exLocks[$key] = $session;
596 $this->sessionIndexEx[$session][$key] = 1;
597 ++$this->lockCount; // we are adding a lock
598 }
599 }
600
601 /**
602 * @param $key string
603 * @param $session string
604 * @return void
605 */
606 protected function unset_sh_lock( $key, $session ) {
607 if ( isset( $this->shLocks[$key][$session] ) ) {
608 unset( $this->shLocks[$key][$session] );
609 if ( !count( $this->shLocks[$key] ) ) {
610 unset( $this->shLocks[$key] );
611 }
612 unset( $this->sessionIndexSh[$session][$key] );
613 if ( !count( $this->sessionIndexSh[$session] ) ) {
614 unset( $this->sessionIndexSh[$session] );
615 }
616 --$this->lockCount;
617 }
618 }
619
620 /**
621 * @param $key string
622 * @param $session string
623 * @return void
624 */
625 protected function unset_ex_lock( $key, $session ) {
626 if ( isset( $this->exLocks[$key] ) && $this->exLocks[$key] === $session ) {
627 unset( $this->exLocks[$key] );
628 unset( $this->sessionIndexEx[$session][$key] );
629 if ( !count( $this->sessionIndexEx[$session] ) ) {
630 unset( $this->sessionIndexEx[$session] );
631 }
632 --$this->lockCount;
633 }
634 }
635 }