Skip to content

[Runtime] a new component to decouple apps from global state #36652

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php73": "^1.11",
"symfony/polyfill-php80": "^1.15",
"symfony/polyfill-uuid": "^1.15"
"symfony/polyfill-uuid": "^1.15",
"symfony/runtime": "self.version"
},
"replace": {
"symfony/asset": "self.version",
Expand Down Expand Up @@ -168,6 +169,10 @@
{
"type": "path",
"url": "src/Symfony/Contracts"
},
{
"type": "path",
"url": "src/Symfony/Component/Runtime"
}
],
"minimum-stability": "dev",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Runtime\SymfonyRuntime;
use Symfony\Component\String\LazyString;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface;
Expand Down Expand Up @@ -114,6 +115,7 @@
service('argument_resolver'),
])
->tag('container.hot_path')
->tag('container.preload', ['class' => SymfonyRuntime::class])
->alias(HttpKernelInterface::class, 'http_kernel')

->set('request_stack', RequestStack::class)
Expand Down
4 changes: 4 additions & 0 deletions src/Symfony/Component/Runtime/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
3 changes: 3 additions & 0 deletions src/Symfony/Component/Runtime/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/vendor/
/composer.lock
/phpunit.xml
139 changes: 139 additions & 0 deletions src/Symfony/Component/Runtime/BaseRuntime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Runtime;

use Symfony\Component\Runtime\Internal\BasicErrorHandler;
use Symfony\Component\Runtime\ResolvedApp\ClosureResolved;
use Symfony\Component\Runtime\ResolvedApp\ScalarResolved;
use Symfony\Component\Runtime\StartedApp\ClosureStarted;

// Help opcache.preload discover always-needed symbols
class_exists(ClosureResolved::class);
class_exists(BasicErrorHandler::class);

/**
* A runtime to do bare-metal PHP without using superglobals.
*
* One option named "debug" is supported; it toggles displaying errors.
*
* The app-closure returned by the entry script must return either:
* - "string" to echo the response content, or
* - "int" to set the exit status code.
*
* The app-closure can declare arguments among either:
* - "array $context" to get a local array similar to $_SERVER;
* - "array $argv" to get the command line arguments when running on the CLI;
* - "array $request" to get a local array with keys "query", "data", "files" and
* "session", which map to $_GET, $_POST, $FILES and &$_SESSION respectively.
*
* The runtime sets up a strict error handler that throws
* exceptions when a PHP warning/notice is raised.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class BaseRuntime implements RuntimeInterface
{
private $debug;

public function __construct(array $options = [])
{
$this->debug = $options['debug'] ?? true;
$errorHandler = new BasicErrorHandler($this->debug);
set_error_handler($errorHandler);
set_exception_handler([$errorHandler, 'handleException']);
}

public function resolve(\Closure $app): ResolvedAppInterface
{
$arguments = [];
$function = new \ReflectionFunction($app);

try {
foreach ($function->getParameters() as $parameter) {
$arguments[] = $this->getArgument($parameter, $parameter->getType());
}
} catch (\InvalidArgumentException $e) {
if (!$parameter->isOptional()) {
throw $e;
}
}

$returnType = $function->getReturnType();

switch ($returnType instanceof \ReflectionNamedType ? $returnType->getName() : '') {
case 'string':
return new ScalarResolved(static function () use ($app, $arguments): int {
echo $app(...$arguments);

return 0;
});

case 'int':
case 'void':
return new ScalarResolved(static function () use ($app, $arguments): int {
return $app(...$arguments) ?? 0;
});
}

return new ClosureResolved($app, $arguments);
}

public function start(object $app): StartedAppInterface
{
if (!$app instanceof \Closure) {
throw new \LogicException(sprintf('"%s" doesn\'t know how to handle apps of type "%s".', get_debug_type($this), get_debug_type($app)));
}

if ($this->debug && (new \ReflectionFunction($app))->getNumberOfRequiredParameters()) {
throw new \ArgumentCountError('Zero argument should be required by the closure returned by the app, but at least one is.');
}

return new ClosureStarted($app);
}

protected function getArgument(\ReflectionParameter $parameter, ?\ReflectionType $type)
{
$type = $type instanceof \ReflectionNamedType ? $type->getName() : '';

if (RuntimeInterface::class === $type) {
return $this;
}

if ('array' !== $type) {
throw new \InvalidArgumentException(sprintf('Cannot resolve argument "%s $%s".', $type, $parameter->name));
}

switch ($parameter->name) {
case 'context':
$context = $_SERVER;

if ($_ENV && !isset($_SERVER['PATH']) && !isset($_SERVER['Path'])) {
$context += $_ENV;
}

return $context;

case 'argv':
return $_SERVER['argv'] ?? [];

case 'request':
return [
'query' => $_GET,
'data' => $_POST,
'files' => $_FILES,
'session' => &$_SESSION,
];
}

throw new \InvalidArgumentException(sprintf('Cannot resolve array argument "$%s", did you mean "$context" or "$request"?', $parameter->name));
}
}
7 changes: 7 additions & 0 deletions src/Symfony/Component/Runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CHANGELOG
=========

5.2.0
-----

* added the component
58 changes: 58 additions & 0 deletions src/Symfony/Component/Runtime/Internal/BasicErrorHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Runtime\Internal;

/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class BasicErrorHandler
{
public function __construct(bool $debug)
{
error_reporting(-1);

if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
ini_set('display_errors', $debug);
} elseif (!filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOLEAN) || ini_get('error_log')) {
// CLI - display errors only if they're not already logged to STDERR
ini_set('display_errors', 1);
}

if (0 <= ini_get('zend.assertions')) {
ini_set('zend.assertions', 1);
ini_set('assert.active', $debug);
ini_set('assert.bail', 0);
ini_set('assert.warning', 0);
ini_set('assert.exception', 1);
}
}

public function __invoke(int $type, string $msg, string $file, int $line): bool
{
if ((E_DEPRECATED | E_USER_DEPRECATED) & $type) {
return true;
}

if ((error_reporting() | E_ERROR | E_RECOVERABLE_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR) & $type) {
throw new \ErrorException($msg, 0, $type, $file, $line);
}

return false;
}

public function handleException(\Throwable $e): void
{
echo "<pre>\n$e\n</pre>\n";
}
}
118 changes: 118 additions & 0 deletions src/Symfony/Component/Runtime/Internal/ComposerPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Runtime\Internal;

use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Factory;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\ScriptEvents;
use Symfony\Component\Filesystem\Filesystem;

/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class ComposerPlugin implements PluginInterface, EventSubscriberInterface
{
/**
* @var Composer
*/
private $composer;

/**
* @var IOInterface
*/
private $io;

private static $activated = false;

public function activate(Composer $composer, IOInterface $io)
{
self::$activated = true;
$this->composer = $composer;
$this->io = $io;
}

public function deactivate(Composer $composer, IOInterface $io)
{
self::$activated = false;
}

public function uninstall(Composer $composer, IOInterface $io)
{
@unlink($composer->getConfig()->get('vendor-dir').'/autoload_runtime.php');
}

public function updateAutoloadFile()
{
$vendorDir = $this->composer->getConfig()->get('vendor-dir');
$autoloadFile = $vendorDir.'/autoload.php';

if (!file_exists($autoloadFile)) {
return;
}

$projectDir = (new Filesystem())->makePathRelative(\dirname(realpath(Factory::getComposerFile())), $vendorDir);
$nestingLevel = 0;

while (0 === strpos($projectDir, '../')) {
++$nestingLevel;
$projectDir = substr($projectDir, 3);
}

if (!$nestingLevel) {
$projectDir = '__DIR__.'.var_export('/'.$projectDir, true);
} else {
$projectDir = "dirname(__DIR__, $nestingLevel)".('' !== $projectDir ? var_export('/'.$projectDir, true) : '');
}

$code = <<<'EOPHP'
<?php

// autoload.php @generated by Symfony Runtime

use Symfony\Component\Runtime\SymfonyRuntime;

if (true === (require_once __DIR__.'/autoload.php') || empty($_SERVER['SCRIPT_FILENAME'])) {
return;
}

if (!($app = require $_SERVER['SCRIPT_FILENAME']) instanceof Closure) {
throw \TypeError(sprintf('Invalid return value: \Closure expected, "%s" returned from "%s".', get_debug_type($app), $_SERVER['SCRIPT_FILENAME']));
}

$runtime = $_SERVER['APP_RUNTIME'] ?? SymfonyRuntime::class;
$runtime = new $runtime(($_SERVER['APP_RUNTIME_OPTIONS'] ?? []) + ['project_dir' => %project_dir%]);
$app = $runtime->resolve($app)();
exit($runtime->start($app)());

EOPHP;

file_put_contents(substr_replace($autoloadFile, '_runtime', -4, 0), strtr($code, [
'%project_dir%' => $projectDir,
]));
}

public static function getSubscribedEvents(): array
{
if (!self::$activated) {
return [];
}

return [
ScriptEvents::POST_AUTOLOAD_DUMP => 'updateAutoloadFile',
];
}
}
19 changes: 19 additions & 0 deletions src/Symfony/Component/Runtime/Internal/MissingDotenv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Runtime\Internal;

/**
* @internal class that should be loaded only when symfony/dotenv is not installed
*/
class MissingDotenv
{
}
Loading