mediawiki-extensions-Scribunto/includes/Engines/LuaStandalone/LuaStandaloneInterpreter.php
thiemowmde 4c4392b4f3 Use PHP's more recent ?? operator where it makes sense
… as well as avoid an overly complicated printf call where a string
cast does the same.

Change-Id: I66b51572d0f2f9e72b9ef7c32f9a62ebb698d739
2024-04-28 17:28:51 +00:00

693 lines
18 KiB
PHP

<?php
namespace MediaWiki\Extension\Scribunto\Engines\LuaStandalone;
use InvalidArgumentException;
use MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaError;
use MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaInterpreter;
use MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaInterpreterNotExecutableError;
use MediaWiki\Extension\Scribunto\Engines\LuaCommon\LuaInterpreterNotFoundError;
use MediaWiki\Extension\Scribunto\ScribuntoException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
use UtfNormal\Validator;
class LuaStandaloneInterpreter extends LuaInterpreter {
/** @var int */
protected static $nextInterpreterId = 0;
/**
* @var LuaStandaloneEngine
*/
public $engine;
/**
* @var bool
*/
public $enableDebug;
/**
* @var resource|bool
*/
public $proc;
/**
* @var resource
*/
public $writePipe;
/**
* @var resource
*/
public $readPipe;
/**
* @var ScribuntoException
*/
public $exitError;
/**
* @var int
*/
public $id;
/**
* @var LoggerInterface
*/
protected $logger;
/**
* @var callable[]
*/
protected $callbacks;
/**
* @param LuaStandaloneEngine $engine
* @param array $options
* @throws LuaInterpreterNotFoundError
* @throws ScribuntoException
*/
public function __construct( $engine, array $options ) {
$this->id = self::$nextInterpreterId++;
if ( $options['errorFile'] === null ) {
$options['errorFile'] = wfGetNull();
}
if ( $options['luaPath'] === null ) {
$path = false;
// Note, if you alter these, also alter getLuaVersion() below
if ( PHP_OS == 'Linux' ) {
if ( PHP_INT_SIZE == 4 ) {
$path = 'lua5_1_5_linux_32_generic/lua';
} elseif ( PHP_INT_SIZE == 8 ) {
$path = 'lua5_1_5_linux_64_generic/lua';
}
} elseif ( PHP_OS == 'Windows' || PHP_OS == 'WINNT' || PHP_OS == 'Win32' ) {
if ( PHP_INT_SIZE == 4 ) {
$path = 'lua5_1_5_Win32_bin/lua5.1.exe';
} elseif ( PHP_INT_SIZE == 8 ) {
$path = 'lua5_1_5_Win64_bin/lua5.1.exe';
}
} elseif ( PHP_OS == 'Darwin' ) {
$path = 'lua5_1_5_mac_lion_fat_generic/lua';
}
if ( $path === false ) {
throw new LuaInterpreterNotFoundError(
'No Lua interpreter was given in the configuration, ' .
'and no bundled binary exists for this platform.' );
}
$options['luaPath'] = __DIR__ . "/binaries/$path";
if ( !is_executable( $options['luaPath'] ) ) {
throw new LuaInterpreterNotExecutableError(
sprintf( 'The lua binary (%s) is not executable.', $options['luaPath'] )
);
}
}
$this->engine = $engine;
$this->enableDebug = !empty( $options['debug'] );
$this->logger = $options['logger'] ?? new NullLogger();
$pipes = null;
$cmd = wfEscapeShellArg(
$options['luaPath'],
__DIR__ . '/mw_main.lua',
dirname( dirname( __DIR__ ) ),
(string)$this->id,
(string)PHP_INT_SIZE
);
if ( php_uname( 's' ) == 'Linux' ) {
// Limit memory and CPU
$cmd = wfEscapeShellArg(
# proc_open() passes $cmd to 'sh -c' on Linux, so add an 'exec' to bypass it
'exec',
'/bin/sh',
__DIR__ . '/lua_ulimit.sh',
# soft limit (SIGXCPU)
(string)$options['cpuLimit'],
# hard limit
(string)( $options['cpuLimit'] + 1 ),
(string)intval( $options['memoryLimit'] / 1024 ),
$cmd );
}
if ( php_uname( 's' ) == 'Windows NT' ) {
// Like the passthru() in older versions of PHP,
// PHP's invokation of cmd.exe in proc_open() is broken:
// http://news.php.net/php.internals/21796
// Unlike passthru(), it is not fixed in any PHP version,
// so we use the fix similar to one in wfShellExec()
$cmd = '"' . $cmd . '"';
}
$this->logger->debug( __METHOD__ . ": creating interpreter: $cmd" );
// Check whether proc_open is available before trying to call it (e.g.
// PHP's disable_functions may have removed it)
if ( !function_exists( 'proc_open' ) ) {
throw $this->engine->newException( 'scribunto-luastandalone-proc-error-proc-open' );
}
// Clear the "last error", so if proc_open fails we can know any
// warning was generated by that.
error_clear_last();
$this->proc = proc_open(
$cmd,
[
[ 'pipe', 'r' ],
[ 'pipe', 'w' ],
[ 'file', $options['errorFile'], 'a' ]
],
$pipes );
if ( !$this->proc ) {
$err = error_get_last();
if ( !empty( $err['message'] ) ) {
throw $this->engine->newException( 'scribunto-luastandalone-proc-error-msg',
[ 'args' => [ $err['message'] ] ] );
} else {
throw $this->engine->newException( 'scribunto-luastandalone-proc-error' );
}
}
$this->writePipe = $pipes[0];
$this->readPipe = $pipes[1];
}
public function __destruct() {
$this->terminate();
}
/**
* Fetch the Lua version
* @param array $options Engine options
* @return string|null
*/
public static function getLuaVersion( array $options ) {
if ( $options['luaPath'] === null ) {
// We know which versions are distributed, no need to run them.
if ( PHP_OS == 'Linux' ) {
return 'Lua 5.1.5';
} elseif ( PHP_OS == 'Windows' || PHP_OS == 'WINNT' || PHP_OS == 'Win32' ) {
return 'Lua 5.1.5';
} elseif ( PHP_OS == 'Darwin' ) {
return 'Lua 5.1.5';
} else {
return null;
}
}
// Ask the interpreter what version it is, using the "-v" option.
// The output is expected to be one line, something like these:
// Lua 5.1.5 Copyright (C) 1994-2012 Lua.org, PUC-Rio
// LuaJIT 2.0.0 -- Copyright (C) 2005-2012 Mike Pall. http://luajit.org/
$cmd = wfEscapeShellArg( $options['luaPath'] ) . ' -v';
$handle = popen( $cmd, 'r' );
if ( $handle ) {
$ret = fgets( $handle, 80 );
pclose( $handle );
if ( $ret && preg_match( '/^Lua(?:JIT)? \S+/', $ret, $m ) ) {
return $m[0];
}
}
return null;
}
public function terminate() {
if ( $this->proc ) {
$this->logger->debug( __METHOD__ . ": terminating" );
proc_terminate( $this->proc );
proc_close( $this->proc );
$this->proc = false;
}
}
public function quit() {
if ( !$this->proc ) {
return;
}
$this->dispatch( [ 'op' => 'quit' ] );
proc_close( $this->proc );
}
public function testquit() {
if ( !$this->proc ) {
return;
}
$this->dispatch( [ 'op' => 'testquit' ] );
proc_close( $this->proc );
}
/**
* @param string $text
* @param string $chunkName
* @return LuaStandaloneInterpreterFunction
*/
public function loadString( $text, $chunkName ) {
$this->cleanupLuaChunks();
$result = $this->dispatch( [
'op' => 'loadString',
'text' => $text,
'chunkName' => $chunkName
] );
return new LuaStandaloneInterpreterFunction( $this->id, $result[1] );
}
/** @inheritDoc */
public function callFunction( $func, ...$args ) {
if ( !( $func instanceof LuaStandaloneInterpreterFunction ) ) {
throw new InvalidArgumentException( __METHOD__ . ': invalid function type' );
}
if ( $func->interpreterId !== $this->id ) {
throw new InvalidArgumentException( __METHOD__ . ': function belongs to a different interpreter' );
}
$args = func_get_args();
unset( $args[0] );
// $args is now conveniently a 1-based array, as required by the Lua server
$this->cleanupLuaChunks();
$result = $this->dispatch( [
'op' => 'call',
'id' => $func->id,
'nargs' => count( $args ),
'args' => $args,
] );
// Convert return values to zero-based
return array_values( $result );
}
/** @inheritDoc */
public function wrapPhpFunction( $callable ) {
static $uid = 0;
$id = "anonymous*" . ++$uid;
$this->callbacks[$id] = $callable;
$ret = $this->dispatch( [
'op' => 'wrapPhpFunction',
'id' => $id,
] );
return $ret[1];
}
public function cleanupLuaChunks() {
if ( isset( LuaStandaloneInterpreterFunction::$anyChunksDestroyed[$this->id] ) ) {
unset( LuaStandaloneInterpreterFunction::$anyChunksDestroyed[$this->id] );
$this->dispatch( [
'op' => 'cleanupChunks',
'ids' => LuaStandaloneInterpreterFunction::$activeChunkIds[$this->id]
] );
}
}
/** @inheritDoc */
public function isLuaFunction( $object ) {
return $object instanceof LuaStandaloneInterpreterFunction;
}
/** @inheritDoc */
public function registerLibrary( $name, array $functions ) {
// Make sure all ids are unique, even when libraries share the same name
// which is especially relevant for "mw_interface" (T211203).
static $uid = 0;
$uid++;
$functionIds = [];
foreach ( $functions as $funcName => $callback ) {
$id = "$name-$funcName-$uid";
$this->callbacks[$id] = $callback;
$functionIds[$funcName] = $id;
}
$this->dispatch( [
'op' => 'registerLibrary',
'name' => $name,
'functions' => $functionIds,
] );
}
/**
* Get interpreter status
* @return array
*/
public function getStatus() {
$result = $this->dispatch( [
'op' => 'getStatus',
] );
return $result[1];
}
public function pauseUsageTimer() {
}
public function unpauseUsageTimer() {
}
/**
* Fill in missing nulls in a list received from Lua
*
* @param array $array List received from Lua
* @param int $count Number of values that should be in the list
* @return array Non-sparse array
*/
private static function fixNulls( array $array, $count ) {
if ( count( $array ) === $count ) {
return $array;
} else {
return array_replace( array_fill( 1, $count, null ), $array );
}
}
/**
* Handle a protocol 'call' message from Lua
* @param array $message
* @return array Response message to send to Lua
*/
protected function handleCall( $message ) {
$message['args'] = self::fixNulls( $message['args'], $message['nargs'] );
try {
$result = $this->callback( $message['id'], $message['args'] );
} catch ( LuaError $e ) {
return [
'op' => 'error',
'value' => $e->getLuaMessage(),
];
}
// Convert to a 1-based array
if ( $result !== null && count( $result ) ) {
$result = array_combine( range( 1, count( $result ) ), $result );
} else {
$result = [];
}
return [
'op' => 'return',
'nvalues' => count( $result ),
'values' => $result
];
}
/**
* Call a registered/wrapped PHP function from Lua
* @param string $id Callback ID
* @param array $args Arguments to pass to the callback
* @return mixed Return value from the callback
*/
protected function callback( $id, array $args ) {
return ( $this->callbacks[$id] )( ...$args );
}
/**
* Handle a protocol error response
*
* Converts the encoded Lua error to an appropriate exception and throws it.
*
* @param array $message
* @return never
*/
protected function handleError( $message ) {
$opts = [];
$message['value'] = Validator::cleanUp( $message['value'] );
if ( preg_match( '/^(.*?):(\d+): (.*)$/', $message['value'], $m ) ) {
$opts['module'] = $m[1];
$opts['line'] = $m[2];
$message['value'] = $m[3];
}
if ( isset( $message['trace'] ) ) {
foreach ( $message['trace'] as &$val ) {
$val = array_map( static function ( $val ) {
if ( is_string( $val ) ) {
$val = Validator::cleanUp( $val );
}
return $val;
}, $val );
}
$opts['trace'] = array_values( $message['trace'] );
}
throw $this->engine->newLuaError( $message['value'], $opts );
}
/**
* Send a protocol message to Lua, and handle any responses
* @param array $msgToLua
* @return mixed Response data
*/
protected function dispatch( $msgToLua ) {
$this->sendMessage( $msgToLua );
while ( true ) {
$msgFromLua = $this->receiveMessage();
switch ( $msgFromLua['op'] ) {
case 'return':
return self::fixNulls( $msgFromLua['values'], $msgFromLua['nvalues'] );
case 'call':
$msgToLua = $this->handleCall( $msgFromLua );
$this->sendMessage( $msgToLua );
break;
case 'error':
$this->handleError( $msgFromLua );
// handleError prevents continuation
default:
$this->logger->error( __METHOD__ . ": invalid response op \"{$msgFromLua['op']}\"" );
throw $this->engine->newException( 'scribunto-luastandalone-decode-error' );
}
}
}
/**
* Send a protocol message to Lua
* @param array $msg
*/
protected function sendMessage( $msg ) {
$this->debug( "TX ==> {$msg['op']}" );
$this->checkValid();
// Send the message
$encMsg = $this->encodeMessage( $msg );
if ( !fwrite( $this->writePipe, $encMsg ) ) {
// Write error, probably the process has terminated
// If it has, handleIOError() will throw. If not, throw an exception ourselves.
$this->handleIOError();
throw $this->engine->newException( 'scribunto-luastandalone-write-error' );
}
}
/**
* Receive a protocol message from Lua
* @return array
*/
protected function receiveMessage() {
$this->checkValid();
// Read the header
$header = fread( $this->readPipe, 16 );
if ( strlen( $header ) !== 16 ) {
$this->handleIOError();
throw $this->engine->newException( 'scribunto-luastandalone-read-error' );
}
$length = $this->decodeHeader( $header );
// Read the reply body
$body = '';
$lengthRemaining = $length;
while ( $lengthRemaining ) {
$buffer = fread( $this->readPipe, $lengthRemaining );
if ( $buffer === false || feof( $this->readPipe ) ) {
$this->handleIOError();
throw $this->engine->newException( 'scribunto-luastandalone-read-error' );
}
$body .= $buffer;
$lengthRemaining -= strlen( $buffer );
}
$body = strtr( $body, [
'\\r' => "\r",
'\\n' => "\n",
'\\\\' => '\\',
] );
$msg = unserialize( $body );
$this->debug( "RX <== {$msg['op']}" );
return $msg;
}
/**
* Encode a protocol message to send to Lua
* @param mixed $message
* @return string
*/
protected function encodeMessage( $message ) {
$serialized = $this->encodeLuaVar( $message );
$length = strlen( $serialized );
$check = $length * 2 - 1;
return sprintf( '%08x%08x%s', $length, $check, $serialized );
}
/**
* @param mixed $var
* @param int $level
*
* @return string
*/
protected function encodeLuaVar( $var, $level = 0 ) {
if ( $level > 100 ) {
throw new RuntimeException( __METHOD__ . ': recursion depth limit exceeded' );
}
$type = gettype( $var );
switch ( $type ) {
case 'boolean':
return $var ? 'true' : 'false';
case 'integer':
return $var;
case 'double':
if ( !is_finite( $var ) ) {
if ( is_nan( $var ) ) {
return '(0/0)';
}
if ( $var === INF ) {
return '(1/0)';
}
if ( $var === -INF ) {
return '(-1/0)';
}
throw new InvalidArgumentException( __METHOD__ . ': cannot convert non-finite number' );
}
return sprintf( '%.17g', $var );
case 'string':
return '"' .
strtr( $var, [
'"' => '\\"',
'\\' => '\\\\',
"\n" => '\\n',
"\r" => '\\r',
"\000" => '\\000',
] ) .
'"';
case 'array':
$s = '{';
foreach ( $var as $key => $element ) {
if ( $s !== '{' ) {
$s .= ',';
}
// Lua's number type can't represent most integers beyond 2**53, so stringify such keys
if ( is_int( $key ) && ( $key > 9007199254740992 || $key < -9007199254740992 ) ) {
$key = (string)$key;
}
$s .= '[' . $this->encodeLuaVar( $key, $level + 1 ) . ']' .
'=' . $this->encodeLuaVar( $element, $level + 1 );
}
$s .= '}';
return $s;
case 'object':
if ( !( $var instanceof LuaStandaloneInterpreterFunction ) ) {
throw new InvalidArgumentException( __METHOD__ . ': unable to convert object of type ' .
get_class( $var ) );
} elseif ( $var->interpreterId !== $this->id ) {
throw new InvalidArgumentException(
__METHOD__ . ': unable to convert function belonging to a different interpreter'
);
} else {
return 'chunks[' . intval( $var->id ) . ']';
}
case 'resource':
throw new InvalidArgumentException( __METHOD__ . ': unable to convert resource' );
case 'NULL':
return 'nil';
default:
throw new InvalidArgumentException( __METHOD__ . ': unable to convert variable of unknown type' );
}
}
/**
* Verify protocol header and extract the body length.
* @param string $header
* @return int Length
*/
protected function decodeHeader( $header ) {
$length = substr( $header, 0, 8 );
$check = substr( $header, 8, 8 );
if ( !preg_match( '/^[0-9a-f]+$/', $length ) || !preg_match( '/^[0-9a-f]+$/', $check ) ) {
throw $this->engine->newException( 'scribunto-luastandalone-decode-error' );
}
$length = hexdec( $length );
$check = hexdec( $check );
if ( $length * 2 - 1 !== $check ) {
throw $this->engine->newException( 'scribunto-luastandalone-decode-error' );
}
return $length;
}
/**
* @throws ScribuntoException
*/
protected function checkValid() {
if ( !$this->proc ) {
$this->logger->error( __METHOD__ . ": process already terminated" );
if ( $this->exitError ) {
throw $this->exitError;
} else {
throw $this->engine->newException( 'scribunto-luastandalone-gone' );
}
}
}
/**
* @throws ScribuntoException
*/
protected function handleIOError() {
$this->checkValid();
// Terminate, fetch the status, then close. proc_close()'s return
// value isn't helpful here because there's no way to differentiate a
// signal-kill from a normal exit.
proc_terminate( $this->proc );
while ( true ) {
$status = proc_get_status( $this->proc );
// XXX: Should proc_get_status docs be changed so that
// its documented as possibly returning false?
'@phan-var array|false $status';
if ( $status === false ) {
// WTF? Let the caller throw an appropriate error.
return;
}
if ( !$status['running'] ) {
break;
}
// Give the killed process a chance to be scheduled
usleep( 10000 );
}
proc_close( $this->proc );
$this->proc = false;
// proc_open() sometimes uses a shell, check for shell-style signal reporting.
if ( !$status['signaled'] && ( $status['exitcode'] & 0x80 ) === 0x80 ) {
$status['signaled'] = true;
$status['termsig'] = $status['exitcode'] - 128;
}
if ( $status['signaled'] ) {
if ( defined( 'SIGXCPU' ) && $status['termsig'] === SIGXCPU ) {
$this->exitError = $this->engine->newException( 'scribunto-common-timeout' );
} else {
$this->exitError = $this->engine->newException( 'scribunto-luastandalone-signal',
[ 'args' => [ $status['termsig'] ] ] );
}
} else {
$this->exitError = $this->engine->newException( 'scribunto-luastandalone-exited',
[ 'args' => [ $status['exitcode'] ] ] );
}
throw $this->exitError;
}
/**
* @param string $msg
*/
protected function debug( $msg ) {
if ( $this->enableDebug ) {
$this->logger->debug( "Lua: $msg" );
}
}
}