From 419d8ac22630a9a7206ce188431ce40b99defe8e Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 11:42:25 +0300 Subject: [PATCH 01/21] ci: update build configuration - add PHP versions 8.3 and 8.4 to the test matrix - update actions/checkout, shivammathur/setup-php, and actions/cache --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7497be7..f6ddd48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,21 +6,22 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] name: PHP ${{ matrix.php }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Install PHP - uses: shivammathur/setup-php@master + uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} + extensions: parallel - name: Validate composer.json and composer.lock run: composer validate - name: Get Composer cache directory id: composer-cache run: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Cache dependencies - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} From ddd3fdba35bd8b1066146ad6e111c0c6fa91e0b2 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 11:43:58 +0300 Subject: [PATCH 02/21] style: update CS fixer configuration --- .php-cs-fixer.php | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 2f20865..c6d95e1 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -3,7 +3,7 @@ /** * php-cs file * contains rubric for code-style fixes - * + * * @package chemem/asyncify * @author Lochemem Bruno Michael * @license Apache-2.0 @@ -15,18 +15,24 @@ use PhpCsFixer\Finder; $finder = Finder::create() - ->exclude(['vendor', 'cache', 'bin']) + ->exclude(['vendor', 'cache']) ->in(__DIR__); $config = new Config; return $config - ->setRules([ - '@PSR12' => true, - 'linebreak_after_opening_tag' => true, - 'binary_operator_spaces' => [ - 'operators' => ['=>' => 'align', '=' => 'align'], - ], - ]) + ->setRules( + [ + '@PSR12' => true, + 'linebreak_after_opening_tag' => true, + 'trailing_comma_in_multiline' => false, + 'binary_operator_spaces' => [ + 'operators' => [ + '=>' => 'align', + '=' => 'align', + ], + ], + ] + ) ->setFinder($finder) ->setIndent(' '); From 737ee300d4c0b04d3bd037ffa02974720bdafc8a Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 11:46:19 +0300 Subject: [PATCH 03/21] feat: add thread function --- src/index.php | 1 + src/internal/thread.php | 89 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/internal/thread.php diff --git a/src/index.php b/src/index.php index 2b415a8..d063be4 100644 --- a/src/index.php +++ b/src/index.php @@ -11,4 +11,5 @@ require_once __DIR__ . '/internal/asyncify.php'; require_once __DIR__ . '/internal/constants.php'; require_once __DIR__ . '/internal/proc.php'; +require_once __DIR__ . '/internal/thread.php'; require_once __DIR__ . '/call.php'; diff --git a/src/internal/thread.php b/src/internal/thread.php new file mode 100644 index 0000000..923b60b --- /dev/null +++ b/src/internal/thread.php @@ -0,0 +1,89 @@ + b) -> Array -> Object -> Promise s b + * + * @internal + * @param string|callable $function + * @param array $args + * @param Runtime $runtime + * @return PromiseInterface + * @example + * + * $runtime = new Runtime(new EventLoopBridge()); + * $data = thread( + * 'file_get_contents', + * ['/path/to/file'], + * $runtime + * ); + * + * $data->then( + * function (string $contents) { + * echo $contents . PHP_EOL; + * }, + * function (Throwable $err) { + * echo $err->getMessage() . PHP_EOL; + * } + * ); + * => file_get_contents(/path/to/file): Failed to open stream: No such file or directory + */ +function thread( + $function, + array $args, + Runtime $runtime +): PromiseInterface { + return new Promise( + function (callable $resolve, callable $reject) use ( + $args, + $function, + $runtime + ) { + $result = $runtime->run( + function ($function, array $args) { + \set_error_handler( + function (...$args) { + [$errno, $errmsg] = $args; + + throw new \Exception($errmsg, $errno); + } + ); + + $result = $function(...$args); + + \restore_error_handler(); + + return $result; + }, + [$function, $args] + ); + + $resolve($result); + } + ); +} From 7465bd0fc7da1c5aee9bf4a601780a4ed98bc621 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 11:48:55 +0300 Subject: [PATCH 04/21] fix: change message --- examples/readFile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/readFile.php b/examples/readFile.php index 5d14cc9..5d8fdc6 100644 --- a/examples/readFile.php +++ b/examples/readFile.php @@ -20,7 +20,7 @@ $call = call('file_get_contents', []) ->then( function (?int $result) { - echo \sprintf("Wrote %d bytes\n", $result); + echo \sprintf("Read %d bytes\n", $result); }, function (\Throwable $err) { echo $err->getMessage() . PHP_EOL; From e0a494505894b05f8a1cbde9c3b0d5ee66096616 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 11:52:48 +0300 Subject: [PATCH 05/21] fix: modify example --- examples/readFile.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/readFile.php b/examples/readFile.php index 5d8fdc6..fd14672 100644 --- a/examples/readFile.php +++ b/examples/readFile.php @@ -19,8 +19,11 @@ $call = call('file_get_contents', []) ->then( - function (?int $result) { - echo \sprintf("Read %d bytes\n", $result); + function (?string $result) { + echo \sprintf( + "Read %d bytes\n", + \strlen($result) + ); }, function (\Throwable $err) { echo $err->getMessage() . PHP_EOL; From 95b3974f46457a8b67dc6252e335f5c52eb6ffd3 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:00:22 +0300 Subject: [PATCH 06/21] feat: add conditional support for CSP --- src/Async.php | 59 +++++++++++++++++++++++++++++++++++++++++++++------ src/call.php | 46 ++++++++++++++++++++++++++++++++++----- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/Async.php b/src/Async.php index e064c4b..0772e0b 100644 --- a/src/Async.php +++ b/src/Async.php @@ -14,8 +14,15 @@ use React\EventLoop\LoopInterface; use React\Promise\PromiseInterface; +use ReactParallel\EventLoop\EventLoopBridge; +use ReactParallel\Runtime\Runtime; use function Chemem\Asyncify\Internal\asyncify; +use function Chemem\Asyncify\Internal\thread; +use function Chemem\Bingo\Functional\extend; +use function Chemem\Bingo\Functional\filePath; + +use const Chemem\Asyncify\Internal\PHP_THREADABLE; class Async { @@ -33,10 +40,29 @@ class Async */ private $autoload; + /** + * Runtime object + * + * @var Runtime $runtime + */ + private $runtime; + public function __construct(?string $autoload = null, ?LoopInterface $loop = null) { $this->loop = $loop; $this->autoload = $autoload; + + if (PHP_THREADABLE) { + $this->runtime = new Runtime( + new EventLoopBridge($this->loop), + $this->autoload ?? filePath(0, 'vendor/autoload.php') + ); + } + } + + public function __destruct() + { + $this->runtime->close(); } /** @@ -49,8 +75,10 @@ public function __construct(?string $autoload = null, ?LoopInterface $loop = nul * @param string $autoload * @return Async */ - public static function create(?string $autoload = null, ?LoopInterface $loop = null): Async - { + public static function create( + ?string $autoload = null, + ?LoopInterface $loop = null + ): Async { return new static($autoload, $loop); } @@ -58,9 +86,9 @@ public static function create(?string $autoload = null, ?LoopInterface $loop = n * call * asynchronously calls a synchronous PHP function and subsumes result in promise * - * call :: String -> Array -> Promise s a + * call :: Sum String (a -> b) -> Array -> Promise s b * - * @param string $function + * @param string|callable $function * @param array $args * @return PromiseInterface * @example @@ -75,11 +103,28 @@ public static function create(?string $autoload = null, ?LoopInterface $loop = n * function (Throwable $err) { * echo $err->getMessage() . PHP_EOL; * } - * ) + * ); * => file_get_contents(/path/to/file): Failed to open stream: No such file or directory */ - public function call(string $function, array $args): PromiseInterface + public function call($function, array $args): PromiseInterface { - return asyncify($function, $args, $this->autoload, $this->loop); + $params = [$function, $args]; + + return PHP_THREADABLE ? + thread( + ...extend( + $params, + [$this->runtime] + ) + ) : + asyncify( + ...extend( + $params, + [ + $this->autoload, + $this->loop + ] + ) + ); } } diff --git a/src/call.php b/src/call.php index a5d1eaf..cf85d21 100644 --- a/src/call.php +++ b/src/call.php @@ -12,7 +12,16 @@ namespace Chemem\Asyncify; +use React\EventLoop\Loop; +use ReactParallel\EventLoop\EventLoopBridge; +use ReactParallel\Runtime\Runtime; + use function Chemem\Bingo\Functional\curry; +use function Chemem\Bingo\Functional\filePath; + +use const Chemem\Asyncify\Internal\asyncify; +use const Chemem\Asyncify\Internal\thread; +use const Chemem\Asyncify\Internal\PHP_THREADABLE; const call = __NAMESPACE__ . '\\call'; @@ -21,7 +30,7 @@ * curryied version of asyncify * -> allows users to bootstrap asynchronous function calls * - * call :: String -> Array -> String -> Object -> (String -> Array -> String -> Object -> Promise s a) -> Promise s a + * call :: Sum String (a -> b) -> Array -> Bool -> (String -> Array -> String -> Object -> Promise s b) * * @param mixed ...$args * @return mixed @@ -35,14 +44,41 @@ * function (Throwable $err) { * echo $err->getMessage() . PHP_EOL; * } - * ) + * ); * => file_get_contents(/path/to/file): Failed to open stream: No such file or directory */ function call(...$args) { - $count = \count($args); + if (!PHP_THREADABLE) { + return curry(asyncify)(...$args); + } + + // globally register runtime object + if (!isset($GLOBALS['RUNTIME'])) { + $GLOBALS['RUNTIME'] = new Runtime( + new EventLoopBridge( + $args[3] ?? Loop::get() + ), + $args[2] ?? filePath(0, 'vendor/autoload.php') + ); + } + + // close runtime on shutdown + \register_shutdown_function( + function () { + if (isset($GLOBALS['RUNTIME'])) { + ($GLOBALS['RUNTIME'])->close(); + unset($GLOBALS['RUNTIME']); + } + } + ); - return curry(Internal\asyncify)( - ...($count === 1 ? \array_merge($args, [null]) : $args) + return curry(thread)( + ...( + \array_merge( + \array_slice($args, 0, 2), + [$GLOBALS['RUNTIME']] + ) + ) ); } From 41a147bcb130601fa093009cfb438b8183091eba Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:04:10 +0300 Subject: [PATCH 07/21] test: revamp testsuite --- tests/AsyncTest.php | 112 +++++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 70 deletions(-) diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 78e0e6b..98cc756 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -13,87 +13,53 @@ use function React\Async\await; use function React\Promise\resolve; +use const Chemem\Asyncify\Internal\PHP_THREADABLE; + class AsyncTest extends TestCase { - public function asyncifyProvider(): array + public static function asyncifyProvider(): array { return [ - // invalid call to user-specified function - [ - [ - '(function (...$args) { if (!\is_file($args[0])) { throw new \Exception("Could not find file: " . $args[0]); } return \file_get_contents($file); })', - [12], - ], - 'Could not find file: 12', - ], - // native PHP function - [ - ['file_get_contents', ['foo.txt']], - concat( - ' ', - 'file_get_contents(foo.txt):', - PHP_VERSION_ID >= 80000 ? 'Failed' : 'failed', - 'to open stream: No such file or directory' - ), - ], - // erroneous call to native PHP function - [ - ['file_get_contents', []], - concat( - ' ', - 'file_get_contents() expects at least 1', - PHP_VERSION_ID >= 80000 ? 'argument,' : 'parameter,', - '0 given' - ), - ], - // trigger error in user-defined function - [ - [ - '(function ($file) { if (!\is_file($file)) { trigger_error("Could not find file " . $file); } return \file_get_contents($file); })', - ['foo.txt'], - ], - 'Could not find file foo.txt', - ], - // check if objects can be passed - [ - [ - '(function (object $list) { return $list->foo; })', - [(object)['foo' => 'foo']], - ], - 'foo', - ], - // check if arrays can be passed - [ - [ - '(function (array $list) { return $list["foo"]; })', - [['foo' => 'foo']], - ], - 'foo', - ], - // check if numbers can be passed [ [ - '(function (int $x) { return $x + 10; })', - [10], + ( + PHP_THREADABLE ? + function (...$args) { + return \file_get_contents(...$args); + } : + <<<'PHP' + ( + function (...$args) { + return \file_get_contents(...$args); + } + ) + PHP + ), + ['foo.txt'] ], - 20, + '/(No such file or directory)/i' ], - // check if objects can be returned [ - [ - '(function (string $x) { return (object)["foo" => $x]; })', - ['foo'], - ], - (object)['foo' => 'foo'], + ['exec', ['echo "foo"']], + '/^(foo)$/' ], - // check if arrays can be returned [ [ - '(function (string $x) { return ["foo" => $x]; })', - ['foo'], + <<<'PHP' + ( + function ($cmd) { + return exec($cmd); + } + ) + PHP, + ['echo "foo"'], ], - ['foo' => 'foo'], - ], + ( + PHP_THREADABLE ? + '/(Call to undefined function)/i' : + '/^(foo)$/' + ) + ] ]; } @@ -111,7 +77,10 @@ function (\Throwable $err) { } )(...$args); - $this->assertEquals($result, $exec); + $this->assertMatchesRegularExpression( + $result, + $exec + ); } /** @@ -129,6 +98,9 @@ function (\Throwable $err) { } )(...$args); - $this->assertEquals($result, $exec); + $this->assertMatchesRegularExpression( + $result, + $exec + ); } } From ab5ee651a13576a0311bf7b04ea473cc55acdc53 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:07:12 +0300 Subject: [PATCH 08/21] feat: define PHP_THREADABLE --- src/internal/constants.php | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/internal/constants.php b/src/internal/constants.php index 2b102ce..e9ad753 100644 --- a/src/internal/constants.php +++ b/src/internal/constants.php @@ -12,20 +12,37 @@ namespace Chemem\Asyncify\Internal; +use ReactParallel\EventLoop\EventLoopBridge; +use ReactParallel\Runtime\Runtime; + +/** + * @var bool PHP_THREADABLE flag with which to ascertain presence of CSP utilities + */ +\define( + __NAMESPACE__ . '\\PHP_THREADABLE', + ( + \extension_loaded('parallel') && + \class_exists(EventLoopBridge::class) && + \class_exists(Runtime::class) + ) +); + /** * @var PHP_EXECUTABLE_TEMPLATE boilerplate for asynchronous execution of a PHP function */ const PHP_EXECUTABLE_TEMPLATE = <<<'PHP' -function handleException(Throwable $exception): void -{ - echo $exception->getMessage(); -} -function handleError(...$args) -{ - throw new \Exception($args[1]); -} -\set_error_handler("handleError", E_ALL); -\set_exception_handler("handleException"); +\set_error_handler( + function (...$args) { + [$errno, $errmsg] = $args; + throw new \Exception($errmsg, $errno); + }, + E_ALL +); +\set_exception_handler( + function (Throwable $err) { + echo $err->getMessage(); + } +); require_once "%s"; echo \base64_encode( \serialize( From 6e10f4794f3836ab37bc2821bf27691ca1fdea1e Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:08:53 +0300 Subject: [PATCH 09/21] feat: revamp asyncify internals --- src/internal/asyncify.php | 47 ++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/internal/asyncify.php b/src/internal/asyncify.php index 8779c9f..dc12eea 100644 --- a/src/internal/asyncify.php +++ b/src/internal/asyncify.php @@ -22,6 +22,8 @@ use function React\Promise\reject; use function React\Promise\resolve; +use const Chemem\Bingo\Functional\toException; + const asyncify = __NAMESPACE__ . '\\asyncify'; /** @@ -45,7 +47,7 @@ * function (Throwable $err) { * echo $err->getMessage() . PHP_EOL; * } - * ) + * ); * => file_get_contents(/path/to/file): Failed to open stream: No such file or directory */ function asyncify( @@ -54,30 +56,43 @@ function asyncify( ?string $autoload = null, ?LoopInterface $loop = null ): PromiseInterface { - // create custom variant of str_replace to format executable code - $replace = partial('str_replace', PHP_EOL, ' '); - return proc( \sprintf( - // executable PHP command - concat('', 'php -r \'', $replace(PHP_EXECUTABLE_TEMPLATE), '\''), - // path to autoloader - \is_null($autoload) ? filePath(0, 'vendor/autoload.php') : $autoload, - // composable exception handler - \Chemem\Bingo\Functional\toException, - // format inline functions - $replace($function), - // utilize only array values as arguments - \base64_encode(\serialize(\array_values($args))) + 'php -r \'%s\'', + \preg_replace( + ['/(\s){2,}/', '/(\\n)/'], + '', + \sprintf( + PHP_EXECUTABLE_TEMPLATE, + // path to autoloader + $autoload ?? filePath(0, 'vendor/autoload.php'), + // composable exception handler + toException, + // format inline functions + $replace($function), + // utilize only array values as arguments + \base64_encode( + \serialize( + \array_values($args) + ) + ) + ) + ) ), $loop ) ->then( function (?string $result) { - $data = \unserialize(\base64_decode($result)); + $data = \unserialize( + \base64_decode($result) + ); if ($data instanceof \Throwable) { - return reject(new \Exception($data->getMessage())); + return reject( + new \Exception( + $data->getMessage() + ) + ); } return resolve($data); From bb88bb8a0b488d55e19f789427929133119eeab6 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:39:04 +0300 Subject: [PATCH 10/21] fix: revamp destructor --- src/Async.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Async.php b/src/Async.php index 0772e0b..0a663dd 100644 --- a/src/Async.php +++ b/src/Async.php @@ -62,7 +62,9 @@ public function __construct(?string $autoload = null, ?LoopInterface $loop = nul public function __destruct() { - $this->runtime->close(); + if (isset($this->runtime)) { + $this->runtime->close(); + } } /** From 49b5c1dfe71e5e45851a483a908791f681f56b29 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:48:46 +0300 Subject: [PATCH 11/21] fix: remove $replace lambda call --- src/internal/asyncify.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/asyncify.php b/src/internal/asyncify.php index dc12eea..5b77638 100644 --- a/src/internal/asyncify.php +++ b/src/internal/asyncify.php @@ -69,7 +69,7 @@ function asyncify( // composable exception handler toException, // format inline functions - $replace($function), + $function, // utilize only array values as arguments \base64_encode( \serialize( From 2cc5ac14942371102e48b08dde80f90e75d8ffbe Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:49:39 +0300 Subject: [PATCH 12/21] test: remove unnecessary imports --- tests/AsyncTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 98cc756..9c63558 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -8,10 +8,8 @@ use PHPUnit\Framework\TestCase; use function Chemem\Asyncify\call; -use function Chemem\Bingo\Functional\concat; use function Chemem\Bingo\Functional\toException; use function React\Async\await; -use function React\Promise\resolve; use const Chemem\Asyncify\Internal\PHP_THREADABLE; From e91cdb621f2717c66a91f6b402884315289f46e9 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:51:43 +0300 Subject: [PATCH 13/21] feat: update project dependencies --- composer.json | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 98f7778..b224b4d 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "chemem/asyncify", - "description": "A package that runs synchronous PHP functions asynchronously.", + "description": "A simple library with which to run blocking I/O in a non-blocking fashion.", "license": "Apache-2.0", "type": "library", "authors": [ @@ -12,15 +12,19 @@ ], "require": { "php": ">=7.2", - "chemem/bingo-functional": "~2", - "react/child-process": "~0", - "react/promise": "~2" + "chemem/bingo-functional": "^2", + "react/child-process": "^0", + "react/promise": "^2 || ^3" }, "require-dev": { - "ergebnis/composer-normalize": "~2", - "friendsofphp/php-cs-fixer": "~2 || ~3", - "phpunit/phpunit": "~8 || ~9", - "react/async": "~3 || ~4" + "ergebnis/composer-normalize": "^2", + "friendsofphp/php-cs-fixer": "^2 || ^3", + "phpunit/phpunit": "^8 || ^9", + "react/async": "^3 || ^4" + }, + "suggest": { + "ext-parallel": "A succinct parallel concurrency API for PHP 8", + "react-parallel/runtime": "Convinence wrapper around the ext-parallel Runtime and ReactPHP" }, "minimum-stability": "stable", "autoload": { From 6f90c991e2cd91f0f08512da5737feeee8ec4fb3 Mon Sep 17 00:00:00 2001 From: ace411 Date: Tue, 25 Feb 2025 12:54:01 +0300 Subject: [PATCH 14/21] docs: update README.md --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1d1d68c..28d65d1 100644 --- a/README.md +++ b/README.md @@ -5,23 +5,20 @@ [![StyleCI](https://github.styleci.io/repos/365018048/shield?branch=master)](https://github.styleci.io/repos/365018048?branch=master) [![asyncify CI](https://github.com/ace411/asyncify/actions/workflows/ci.yml/badge.svg)](https://github.com/ace411/asyncify/actions/workflows/ci.yml) [![License](http://poser.pugx.org/chemem/asyncify/license)](https://packagist.org/packages/chemem/asyncify) -[![composer.lock](http://poser.pugx.org/chemem/asyncify/composerlock)](https://packagist.org/packages/chemem/asyncify) -[![Dependents](http://poser.pugx.org/chemem/asyncify/dependents)](https://packagist.org/packages/chemem/asyncify) [![Latest Stable Version](http://poser.pugx.org/chemem/asyncify/v)](https://packagist.org/packages/chemem/asyncify) +[![PHP Version Require](http://poser.pugx.org/chemem/asyncify/require/php)](https://packagist.org/packages/chemem/asyncify) -A simple PHP library that runs your synchronous PHP functions asynchronously. +A simple library with which to run blocking I/O in a non-blocking fashion. ## Requirements -- PHP 7.2 or higher +- PHP 7.2 or newer ## Rationale -PHP is a largely synchronous (blocking) runtime. Asynchrony - achievable via ReactPHP and other similar suites - is a potent approach to mitigating the arduousness of I/O operations that feature prominently in day-to-day programming. Melding blocking and non-blocking routines in PHP can be a tricky proposition: when attempted haphazardly, it can yield unsightly outcomes. - -The impetus for creating and maintaining `asyncify` is combining blocking and non-blocking PHP. Built atop ReactPHP, `asyncify` is a tool that allows one to run blocking PHP functions in an event-driven I/O environment. +PHP is home to a host of functions that condition CPU idleness between successive (serial) executions—blocking functions. The expense of blocking calls—invocations of such functions—is such that they can, when deployed haphazardly in evented systems, inflict unnecessary CPU waiting behavior whilst the kernel attempts to interleave non-blocking calls. `asyncify` is a bridge between the blocking I/O in the language userspace and the evented I/O in ReactPHP. It allows those who choose to avail themselves of it the ability to run their blocking code, with minimal plumbing, in evented systems, without derailing them. ## Installation @@ -31,6 +28,12 @@ Though it is possible to clone the repo, Composer remains the best tool for inst $ composer require chemem/asyncify ``` +Newer versions of the library prioritize multithreading. The precondition for operationalizing multithreading is installing the [parallel](https://github.com/krakjoe/parallel) extension (`ext-parallel`) and [`react-parallel/runtime`](https://github.com/reactphp-parallel/runtime) library which can be done in a single step as in the snippet below. + +```sh +$ pie install pecl/parallel ; composer require react-parallel/runtime +``` + ## Usage If you want to take a Functional Programming approach, facilitated by currying, the example below should suffice. @@ -74,7 +77,33 @@ The examples directory contains more nuanced uses of the library that I recommen - `asyncify` is no panacea, but is capable of asynchronously executing a plethora of blocking calls. As presently constituted, the library is **incapable of processing inputs and outputs that cannot be serialized**. Its quintessential asynchronous function application primitive - `call()` - works almost exclusively with string encodings of native language functions and lambdas imported via an autoloading mechanism. -- The library cannot parse closures. All executable arbitrary code should be emplaced in a string whose sole constituent is an immediately invokable anonymous function the format of which is `(function (...$args) { /* signature */ })`. +- The library, in its default configuration, cannot parse closures. All executable arbitrary code should be emplaced in a string whose sole constituent is an immediately invokable anonymous function the format of which is `(function (...$args) { /* signature */ })`. + +## Multithreading + +With multithreading enabled, it is possible to invoke closures and other lambdas without necessarily representing them as strings. Although string encodings are still workable, lambdas like closures should be the preferred option for representing arbitrary blocking logic. The code in the following example should work with multithreading enabled. + +```php +use function Chemem\Asyncify\call; + +$exec = call( + function (...$args) { + return \file_get_contents(...$args); + }, + ['/path/to/file'] +); + +$exec->then( + function (string $contents) { + echo $contents; + }, + function (\Throwable $err) { + echo $err->getMessage(); + } +); +``` + +> It must be noted that string representations of lambdas (anonymous functions, closures and such) that are compatible with the default child process configuration, are not usable in versions that support multithreading. ## API Reference @@ -83,11 +112,16 @@ The examples directory contains more nuanced uses of the library that I recommen ```php namespace Chemem\Asyncify; +use React\{ + EventLoop\LoopInterface, + Promise\PromiseInterface, +}; + class Async { /* Methods */ - public static create( ?string $autoload = null [, ?React\EventLoop\LoopInterface $rootDir = null ] ) : Async; - public function call( string $function [, array $args ] ) : React\Promise\PromiseInterface; + public static create( ?string $autoload = null [, ?LoopInterface $rootDir = null ] ) : Async; + public function call( string|callable $function [, array $args ] ) : PromiseInterface; } ``` @@ -100,7 +134,12 @@ class Async { ```php namespace Chemem\Asyncify; -call ( string $func [, array $args [, ?string $autoload = null [, ?React\EventLoop\LoopInterface $args = null ] ] ] ) : React\Promise\PromiseInterface; +use React\{ + EventLoop\LoopInterface, + Promise\PromiseInterface, +}; + +call ( string|callable $func [, array $args [, ?string $autoload = null [, ?LoopInterface $args = null ] ] ] ) : PromiseInterface; ``` `call` - Curryied function that bootstraps asynchronous function calls From 7a9887fe0931003f43b127b6bf7cf1e56d4529e8 Mon Sep 17 00:00:00 2001 From: ace411 Date: Wed, 26 Feb 2025 11:50:33 +0300 Subject: [PATCH 15/21] test: change nowdoc to heredoc --- tests/AsyncTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 9c63558..01d8d5a 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -25,13 +25,13 @@ public static function asyncifyProvider(): array function (...$args) { return \file_get_contents(...$args); } : - <<<'PHP' + << Date: Thu, 27 Feb 2025 01:38:38 +0300 Subject: [PATCH 16/21] test: revamp testsuite --- tests/AsyncTest.php | 89 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 01d8d5a..2fd58d3 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -25,13 +25,7 @@ public static function asyncifyProvider(): array function (...$args) { return \file_get_contents(...$args); } : - << $value]; + } : + '(function (string $value) { return ["foo" => $value]; })' + ), + ['foo'] + ], + ['foo' => 'foo'], + ], + [ + [ + ( + PHP_THREADABLE ? + function (int $value) { + return (object) ['foo' => $value]; + } : + '(function (int $value) { return (object) ["foo" => $value]; })' + ), + [12] + ], + (object) ['foo' => 12] + ], + [ + [ + ( + PHP_THREADABLE ? + function (int $next) { + if ($next < 3) { + throw new \Exception('Invalid argument'); + } + + return $next + 2; + } : + '(function (int $next) { if ($next < 3) { throw new Exception("Invalid argument"); } return $next + 2; })' + ), + [2] + ], + '/(Invalid argument)/i' ] ]; } @@ -75,10 +106,17 @@ function (\Throwable $err) { } )(...$args); - $this->assertMatchesRegularExpression( - $result, - $exec - ); + if (\is_string($result)) { + $this->assertMatchesRegularExpression( + $result, + $exec + ); + } else { + $this->assertEquals( + $result, + $exec + ); + } } /** @@ -96,9 +134,16 @@ function (\Throwable $err) { } )(...$args); - $this->assertMatchesRegularExpression( - $result, - $exec - ); + if (\is_string($result)) { + $this->assertMatchesRegularExpression( + $result, + $exec + ); + } else { + $this->assertEquals( + $result, + $exec + ); + } } } From 16481a30bf92eca5dc42752034066b5d37e79eb4 Mon Sep 17 00:00:00 2001 From: ace411 Date: Mon, 3 Mar 2025 18:13:08 +0300 Subject: [PATCH 17/21] feat: add helper functions - add curry and filepath functions --- src/internal/functional/curry.php | 54 ++++++++++++++++++++++++++++ src/internal/functional/filepath.php | 35 ++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/internal/functional/curry.php create mode 100644 src/internal/functional/filepath.php diff --git a/src/internal/functional/curry.php b/src/internal/functional/curry.php new file mode 100644 index 0000000..c3aecd5 --- /dev/null +++ b/src/internal/functional/curry.php @@ -0,0 +1,54 @@ + c) -> Bool -> a -> b -> c + * + * @internal + * @param callable $func + * @param boolean $required + * @return callable + */ +function curry(callable $function) +{ + $paramc = ( + new \ReflectionFunction($function) + ) + ->getNumberOfRequiredParameters(); + $acc = function ($args) use ( + &$acc, + $function, + $paramc + ) { + return function (...$inner) use ( + &$acc, + $args, + $function, + $paramc + ) { + $final = \array_merge($args, $inner); + + if ($paramc <= \count($final)) { + return $function(...$final); + } + + return $acc($final); + }; + }; + + return $acc([]); +} diff --git a/src/internal/functional/filepath.php b/src/internal/functional/filepath.php new file mode 100644 index 0000000..fdf86fc --- /dev/null +++ b/src/internal/functional/filepath.php @@ -0,0 +1,35 @@ + String -> String + * + * @internal + * @param int $level + * @param string ...$components + * @return string + */ +function filepath(int $level, string ...$components): string +{ + return implode( + '/', + \array_merge( + [\dirname(__DIR__, $level + 3)], + $components + ) + ); +} From 28fe002bfacc26f179676932378abebb57f91464 Mon Sep 17 00:00:00 2001 From: ace411 Date: Mon, 3 Mar 2025 18:21:28 +0300 Subject: [PATCH 18/21] refactor: replace bingo-functional helpers - replace filePath and curry functions with similarly named local functions - remove redundant imports --- src/Async.php | 9 ++-- src/call.php | 6 +-- src/index.php | 4 +- src/internal/asyncify.php | 13 ++---- src/internal/constants.php | 32 +++++++------- tests/AsyncTest.php | 90 +++++++++++++++++++++++++++----------- 6 files changed, 93 insertions(+), 61 deletions(-) diff --git a/src/Async.php b/src/Async.php index 0a663dd..01c0fd0 100644 --- a/src/Async.php +++ b/src/Async.php @@ -19,8 +19,7 @@ use function Chemem\Asyncify\Internal\asyncify; use function Chemem\Asyncify\Internal\thread; -use function Chemem\Bingo\Functional\extend; -use function Chemem\Bingo\Functional\filePath; +use function Chemem\Asyncify\Internal\Functional\filepath; use const Chemem\Asyncify\Internal\PHP_THREADABLE; @@ -55,7 +54,7 @@ public function __construct(?string $autoload = null, ?LoopInterface $loop = nul if (PHP_THREADABLE) { $this->runtime = new Runtime( new EventLoopBridge($this->loop), - $this->autoload ?? filePath(0, 'vendor/autoload.php') + $this->autoload ?? filepath(0, 'vendor/autoload.php') ); } } @@ -114,13 +113,13 @@ public function call($function, array $args): PromiseInterface return PHP_THREADABLE ? thread( - ...extend( + ...\array_merge( $params, [$this->runtime] ) ) : asyncify( - ...extend( + ...\array_merge( $params, [ $this->autoload, diff --git a/src/call.php b/src/call.php index cf85d21..e309414 100644 --- a/src/call.php +++ b/src/call.php @@ -16,8 +16,8 @@ use ReactParallel\EventLoop\EventLoopBridge; use ReactParallel\Runtime\Runtime; -use function Chemem\Bingo\Functional\curry; -use function Chemem\Bingo\Functional\filePath; +use function Chemem\Asyncify\Internal\Functional\curry; +use function Chemem\Asyncify\Internal\Functional\filepath; use const Chemem\Asyncify\Internal\asyncify; use const Chemem\Asyncify\Internal\thread; @@ -59,7 +59,7 @@ function call(...$args) new EventLoopBridge( $args[3] ?? Loop::get() ), - $args[2] ?? filePath(0, 'vendor/autoload.php') + $args[2] ?? filepath(0, 'vendor/autoload.php') ); } diff --git a/src/index.php b/src/index.php index d063be4..b22d4ba 100644 --- a/src/index.php +++ b/src/index.php @@ -1,13 +1,15 @@ getMessage(), - $err->getCode(), - $err->getPrevious() - ); - } - )(...\unserialize(\base64_decode("%s"))) - ) + \serialize($result) ); PHP; diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 2fd58d3..2bde8af 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -8,7 +8,6 @@ use PHPUnit\Framework\TestCase; use function Chemem\Asyncify\call; -use function Chemem\Bingo\Functional\toException; use function React\Async\await; use const Chemem\Asyncify\Internal\PHP_THREADABLE; @@ -88,6 +87,23 @@ function (int $next) { [2] ], '/(Invalid argument)/i' + ], + [ + [ + ( + PHP_THREADABLE ? + function (int $val) { + if ($next < 10) { + \trigger_error('Value is less than 10'); + + return $val * 2; + } + } : + '(function (int $x) { if ($x < 10) { \trigger_error("Value is less than 10"); return $x; } return $x * 2; })' + ), + [2] + ], + '/(Value is less than 10)/i' ] ]; } @@ -97,20 +113,33 @@ function (int $next) { */ public function testcallRunsSynchronousPHPFunctionAsynchronously($args, $result): void { - $exec = toException( - function (...$args) { - return await(call(...$args)); - }, - function (\Throwable $err) { - return $err->getMessage(); - } - )(...$args); + $exec = null; + try { + $exec = await( + call(...$args) + ); + } catch (\Throwable $err) { + $exec = $err->getMessage(); + } + + $this->assertTrue( + call($args[0]) instanceof \Closure + ); if (\is_string($result)) { - $this->assertMatchesRegularExpression( - $result, - $exec - ); + if (PHP_VERSION_ID < 73000) { + $this->assertTrue( + (bool) \preg_match( + $result, + $exec + ) + ); + } else { + $this->assertMatchesRegularExpression( + $result, + $exec + ); + } } else { $this->assertEquals( $result, @@ -124,21 +153,30 @@ function (\Throwable $err) { */ public function testAsynccallMethodRunsSynchronousPHPFunctionAsynchronously($args, $result): void { - $exec = toException( - function (...$args) { - $async = Async::create(); - return await($async->call(...$args)); - }, - function (\Throwable $err) { - return $err->getMessage(); - } - )(...$args); + $exec = null; + try { + $async = Async::create(); + $exec = await( + $async->call(...$args) + ); + } catch (\Throwable $err) { + $exec = $err->getMessage(); + } if (\is_string($result)) { - $this->assertMatchesRegularExpression( - $result, - $exec - ); + if (PHP_VERSION_ID < 73000) { + $this->assertTrue( + (bool) \preg_match( + $result, + $exec + ) + ); + } else { + $this->assertMatchesRegularExpression( + $result, + $exec + ); + } } else { $this->assertEquals( $result, From 2de51ab944c813b548c0e44b0c43ac33bc51f6b8 Mon Sep 17 00:00:00 2001 From: ace411 Date: Mon, 3 Mar 2025 18:44:21 +0300 Subject: [PATCH 19/21] refactor: instate stream-to-promise converter --- src/internal/proc.php | 63 +++++++++++---------------------- tests/Internal/InternalTest.php | 19 +++++----- 2 files changed, 29 insertions(+), 53 deletions(-) diff --git a/src/internal/proc.php b/src/internal/proc.php index ab3a781..4acf640 100644 --- a/src/internal/proc.php +++ b/src/internal/proc.php @@ -14,7 +14,7 @@ use React\ChildProcess\Process; use React\EventLoop\LoopInterface; -use React\Promise\Deferred; +use React\Promise\Promise; use React\Promise\PromiseInterface; const proc = __NAMESPACE__ . '\\proc'; @@ -41,54 +41,31 @@ */ function proc(string $process, ?LoopInterface $loop = null): PromiseInterface { - $proc = new Process($process); - $result = new Deferred(); + $proc = new Process($process); $proc->start($loop); - if (!$proc->stdout->isReadable()) { - $result->reject( - new \Exception( - \sprintf('Could not process "%s"', $process) - ) - ); + $data = ''; + $action = function (string $chunk) use (&$data) { + $data .= $chunk; + }; - return $result; - } + $proc->stdout->on('data', $action); - $proc->stdout->on( - 'data', - function ($chunk) use (&$result) { - $result->resolve($chunk); - } - ); - - // reject promise in the event of failure - $proc->stdout->on( - 'error', - function (\Throwable $err) use (&$result, &$proc) { - $result->reject($err); - } - ); - - // handle successful closure of the process stream - $proc->stdout->on( - 'end', - function () use (&$result) { - $result->resolve(true); - } - ); + return new Promise( + function (callable $resolve, callable $reject) use (&$data, $proc) { + $proc->stdout->on( + 'error', + function (\Throwable $err) use ($reject) { + $reject($err); + } + ); - // handle unsuccessful closure of process stream - $proc->stdout->on( - 'close', - function () use (&$result, $process) { - $result->reject( - new \Exception( - \sprintf('Closed process "%s"', $process) - ) + $proc->stdout->on( + 'end', + function () use (&$data, $resolve) { + $resolve($data); + } ); } ); - - return $result->promise(); } diff --git a/tests/Internal/InternalTest.php b/tests/Internal/InternalTest.php index 8d5a8fc..54fef58 100644 --- a/tests/Internal/InternalTest.php +++ b/tests/Internal/InternalTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\TestCase; use function Chemem\Asyncify\Internal\proc; -use function Chemem\Bingo\Functional\toException; use function React\Async\await; class InternalTest extends TestCase @@ -20,7 +19,7 @@ public function procProvider(): array // php commandline process [['php -r \'echo "foo";\''], 'foo'], // invalid input - [['kat --foo'], 'Closed process "kat --foo"'], + [['kat --foo'], ''], ]; } @@ -29,14 +28,14 @@ public function procProvider(): array */ public function testprocExecutesCommandAsynchronouslyInChildProcess($args, $result): void { - $exec = toException( - function (...$args) { - return await(proc(...$args)); - }, - function ($err) { - return $err->getMessage(); - } - )(...$args); + $exec = null; + try { + $exec = await( + proc(...$args) + ); + } catch (\Throwable $err) { + $exec = $err->getMessage(); + } $this->assertEquals($result, $exec); } From 6c6f9835dcef1684a2c6c6feb5031da105998077 Mon Sep 17 00:00:00 2001 From: ace411 Date: Mon, 3 Mar 2025 18:45:30 +0300 Subject: [PATCH 20/21] feat: uninstall bingo-functional --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index b224b4d..8c768d1 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,6 @@ ], "require": { "php": ">=7.2", - "chemem/bingo-functional": "^2", "react/child-process": "^0", "react/promise": "^2 || ^3" }, @@ -24,7 +23,7 @@ }, "suggest": { "ext-parallel": "A succinct parallel concurrency API for PHP 8", - "react-parallel/runtime": "Convinence wrapper around the ext-parallel Runtime and ReactPHP" + "react-parallel/runtime": "Convenience wrapper around the ext-parallel Runtime and ReactPHP" }, "minimum-stability": "stable", "autoload": { From 2bc995b390cff049e459194d9401b086ff5aab8d Mon Sep 17 00:00:00 2001 From: ace411 Date: Mon, 3 Mar 2025 18:46:06 +0300 Subject: [PATCH 21/21] docs: update README.md --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 28d65d1..0a8ee63 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,12 @@ Though it is possible to clone the repo, Composer remains the best tool for inst $ composer require chemem/asyncify ``` -Newer versions of the library prioritize multithreading. The precondition for operationalizing multithreading is installing the [parallel](https://github.com/krakjoe/parallel) extension (`ext-parallel`) and [`react-parallel/runtime`](https://github.com/reactphp-parallel/runtime) library which can be done in a single step as in the snippet below. +Newer versions of the library prioritize multithreading. The precondition for operationalizing multithreading is installing the [parallel](https://github.com/krakjoe/parallel) extension (`ext-parallel`) and [`react-parallel/runtime`](https://github.com/reactphp-parallel/runtime) library which can be done with the directives in the snippet below. ```sh -$ pie install pecl/parallel ; composer require react-parallel/runtime +$ pie install pecl/parallel +$ echo "\nextension=parallel" >> "/path/to/php.ini" +$ composer require react-parallel/runtime ``` ## Usage @@ -144,7 +146,10 @@ call ( string|callable $func [, array $args [, ?string $autoload = null [, ?Loop `call` - Curryied function that bootstraps asynchronous function calls -> **Note:** `asyncify` utilizes the autoload file in the root directory of the project from which it is invoked. +### Important Considerations + +- `asyncify`, by default, utilizes the autoload file (`autoload.php`) in the `vendor` directory of the composer project in which it resides. +- The library converts all errors in the functions slated for non-blocking execution to exceptions. ## Dealing with problems