├── .github ├── CODEOWNERS ├── FUNDING.yml ├── boring-cyborg.yml ├── renovate.json ├── settings.yml └── workflows │ ├── ci.yml │ └── craft-release.yaml ├── LICENSE ├── README.md ├── benchmark └── memory.php ├── bin └── child-process.source ├── build ├── .travis.php.ini ├── custom_php_ini.sh ├── handle_brew_pkg.sh ├── osx_install_composer.sh └── prepare_osx_env.sh ├── composer-require-checker.json ├── composer.json ├── composer.lock ├── etc └── qa │ ├── composer-require-checker.json │ ├── phpcs.xml │ ├── phpstan.neon │ ├── phpunit.xml │ └── psalm.xml ├── infection.json.dist ├── src ├── ChildInterface.php ├── ChildProcess │ ├── ArgvEncoder.php │ ├── DoesNotImplementChildInterfaceException.php │ ├── Factory.php │ ├── Options.php │ └── Process.php ├── CommunicationWithProcessUnexpectedEndException.php ├── Factory.php ├── Messages │ ├── ActionableMessageInterface.php │ ├── Error.php │ ├── Factory.php │ ├── Line.php │ ├── LineDecoder.php │ ├── LineEncoder.php │ ├── LineInterface.php │ ├── Message.php │ ├── Payload.php │ ├── Rpc.php │ ├── RpcError.php │ ├── RpcSuccess.php │ └── SecureLine.php ├── Messenger.php ├── MessengerInterface.php ├── OutstandingCall.php ├── OutstandingCallInterface.php ├── OutstandingCalls.php ├── ProcessUnexpectedEndException.php ├── ReturnChild.php └── StaticConfig.php └── var └── .gitkeep /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @WyriHaximus 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: WyriHaximus 2 | -------------------------------------------------------------------------------- /.github/boring-cyborg.yml: -------------------------------------------------------------------------------- 1 | labelPRBasedOnFilePath: 2 | "Documentation 📚": 3 | - README.md 4 | - CONTRIBUTING.md 5 | "Dependencies 📦": 6 | - Dockerfile* 7 | - composer.* 8 | - package.json 9 | - package-lock.json 10 | - yarn.lock 11 | "Docker 🐳": 12 | - Dockerfile* 13 | - .docker/**/* 14 | "Image 🖼": 15 | - "**/*.gif" 16 | - "**/*.jpg" 17 | - "**/*.jpeg" 18 | - "**/*.png" 19 | - "**/*.webp" 20 | "CSS 👩‍🎨": 21 | - "**/*.css" 22 | "HTML 👷‍♀️": 23 | - "**/*.htm" 24 | - "**/*.html" 25 | "NEON 🦹‍♂️": 26 | - "**/*.neon" 27 | "MarkDown 📝": 28 | - "**/*.md" 29 | "YAML 🍄": 30 | - "**/*.yml" 31 | - "**/*.yaml" 32 | "JSON 👨‍💼": 33 | - "**/*.json" 34 | "Go 🐹": 35 | - "**/*.go" 36 | "JavaScript 🦏": 37 | - "**/*.js" 38 | - package.json 39 | - package-lock.json 40 | - yarn.lock 41 | "PHP 🐘": 42 | - "**/*.php" 43 | - composer.* 44 | "Configuration ⚙": 45 | - .github/* 46 | "CI 🚧": 47 | - .github/workflows/* 48 | - .scrutinizer.yml 49 | "Templates 🌲": 50 | - "**/*.twig" 51 | - "**/*.tpl" 52 | "Helm ☸": 53 | - .helm/**/* 54 | "Tests 🧪": 55 | - tests/**/* 56 | "Source 🔮": 57 | - src/**/* 58 | 59 | labelerFlags: 60 | labelOnPRUpdates: true 61 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "packageRules": [ 4 | { 5 | "managers": ["composer"], 6 | "rangeStrategy": "in-range-only" 7 | }, 8 | { 9 | "managers": ["composer"], 10 | "rangeStrategy": "bump" 11 | }, 12 | { 13 | "managers": ["composer"], 14 | "matchPackageNames": ["php"], 15 | "enabled": false 16 | } 17 | ], 18 | "extends": [ 19 | "config:base", 20 | ":widenPeerDependencies", 21 | ":rebaseStalePrs", 22 | ":prHourlyLimitNone", 23 | ":prConcurrentLimitNone", 24 | "group:phpstan" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | private: false 3 | has_issues: true 4 | has_wiki: false 5 | has_downloads: true 6 | default_branch: master 7 | allow_squash_merge: false 8 | allow_merge_commit: true 9 | allow_rebase_merge: false 10 | 11 | # Labels: define labels for Issues and Pull Requests 12 | labels: 13 | - name: "Dependencies 📦" 14 | color: 0025ff 15 | description: "Pull requests that update a dependency file" 16 | - name: "Image 🖼" 17 | color: 00ffff 18 | - name: "HTML 👷‍♀️" 19 | color: ffffff 20 | - name: "CSS 👩‍🎨" 21 | color: b3b3b3 22 | - name: "JavaScript 🦏" 23 | color: ffff00 24 | - name: "Go 🐹" 25 | color: 00ADD8 26 | - name: "JSON 👨‍💼" 27 | color: 00ADD8 28 | - name: "NEON 🦹‍♂️" 29 | color: CE3262 30 | - name: "MarkDown 📝" 31 | color: 000000 32 | - name: "YAML 🍄" 33 | color: ff1aff 34 | - name: "Templates 🌲" 35 | color: 009933 36 | - name: "Helm ☸" 37 | color: 091C84 38 | - name: "Tests 🧪" 39 | color: ffe6e6 40 | - name: "Source 🔮" 41 | color: e6ffe6 42 | - name: "Configuration ⚙" 43 | color: b3b3cc 44 | - name: "PHP 🐘" 45 | color: 8892BF 46 | description: "Hypertext Pre Processor" 47 | - name: "Docker 🐳" 48 | color: 0db7ed 49 | description: "Pull requests that relate to Docker" 50 | - name: "CI 🚧" 51 | color: ffff00 52 | - name: "Feature 🏗" 53 | color: 66ff99 54 | - name: "Documentation 📚" 55 | color: 6666ff 56 | - name: "Security 🕵️‍♀️" 57 | color: ff0000 58 | - name: "Hacktoberfest 🎃" 59 | color: 152347 60 | - name: "Bug 🐞" 61 | color: d73a4a 62 | description: "Something isn't working" 63 | oldname: bug 64 | - name: "Duplicate ♊" 65 | color: cfd3d7 66 | description: "This issue or pull request already exists" 67 | oldname: duplicate 68 | - name: "Enhancement ✨" 69 | color: a2eeef 70 | description: "New feature or request" 71 | oldname: enhancement 72 | - name: "Good First Issue" 73 | color: 7057ff 74 | description: "Good for newcomers" 75 | oldname: "good first issue" 76 | - name: "Help Wanted" 77 | color: 008672 78 | description: "Extra attention is needed" 79 | oldname: "help wanted" 80 | - name: Invalid 81 | color: e4e669 82 | description: "This doesn't seem right" 83 | oldname: invalid 84 | - name: "Question ❓" 85 | color: d876e3 86 | description: "Further information is requested" 87 | oldname: question 88 | - name: "Will not be fixed 🛑" 89 | color: ffffff 90 | description: "This will not be worked on" 91 | oldname: wontfix 92 | - name: "Sponsor Request ❤️" 93 | color: fedbf0 94 | description: "Issue/PR opened by sponsor" 95 | 96 | branches: 97 | - name: master 98 | protection: 99 | required_pull_request_reviews: 100 | required_approving_review_count: 1 101 | dismiss_stale_reviews: true 102 | require_code_owner_reviews: true 103 | # Required. Require status checks to pass before merging. Set to null to disable 104 | required_status_checks: 105 | # Required. Require branches to be up to date before merging. 106 | strict: true 107 | # Required. The list of status checks to require in order to merge into this branch 108 | contexts: [] 109 | # Required. Enforce all configured restrictions for administrators. Set to true to enforce required status checks for repository administrators. Set to null to disable. 110 | enforce_admins: true 111 | # Required. Restrict who can push to this branch. Team and user restrictions are only available for organization-owned repositories. Set to null to disable. 112 | restrictions: 113 | apps: [] 114 | users: [] 115 | teams: [] 116 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | - 'master' 7 | - 'refs/heads/v[0-9]+.[0-9]+.[0-9]+' 8 | pull_request: 9 | ## This workflow needs the `pull-request` permissions to work for the package diffing 10 | ## Refs: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions 11 | permissions: 12 | pull-requests: write 13 | contents: read 14 | jobs: 15 | ci: 16 | name: Continuous Integration 17 | uses: WyriHaximus/github-workflows/.github/workflows/package.yaml@main 18 | -------------------------------------------------------------------------------- /.github/workflows/craft-release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | env: 3 | MILESTONE: ${{ github.event.milestone.title }} 4 | on: 5 | milestone: 6 | types: 7 | - closed 8 | jobs: 9 | wait-for-status-checks: 10 | name: Wait for status checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: sleep 13 15 | - name: 'Wait for status checks' 16 | id: waitforstatuschecks 17 | uses: "WyriHaximus/github-action-wait-for-status@master" 18 | with: 19 | ignoreActions: "Wait for status checks" 20 | checkInterval: 5 21 | env: 22 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 23 | - id: generate-version-strategy 24 | if: steps.waitforstatuschecks.outputs.status != 'success' 25 | name: Fail 26 | run: exit 1 27 | generate-changelog: 28 | name: Generate Changelog 29 | needs: 30 | - wait-for-status-checks 31 | runs-on: ubuntu-latest 32 | outputs: 33 | changelog: ${{ steps.changelog.outputs.changelog }} 34 | steps: 35 | - name: Generate changelog 36 | uses: WyriHaximus/github-action-jwage-changelog-generator@v1 37 | id: changelog 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | with: 41 | milestone: ${{ env.MILESTONE }} 42 | - name: Show changelog 43 | run: echo "${CHANGELOG}" 44 | env: 45 | CHANGELOG: ${{ steps.changelog.outputs.changelog }} 46 | create-release: 47 | name: Create Release 48 | needs: 49 | - generate-changelog 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | env: 54 | CHANGELOG: ${{ needs.generate-changelog.outputs.changelog }} 55 | - run: | 56 | echo -e "${MILESTONE_DESCRIPTION}\r\n\r\n${CHANGELOG}" > release-${{ env.MILESTONE }}-release-message.md 57 | cat release-${{ env.MILESTONE }}-release-message.md 58 | release_message=$(cat release-${{ env.MILESTONE }}-release-message.md) 59 | release_message="${release_message//'%'/'%25'}" 60 | release_message="${release_message//$'\n'/'%0A'}" 61 | release_message="${release_message//$'\r'/'%0D'}" 62 | echo "::set-output name=release_message::$release_message" 63 | id: releasemessage 64 | env: 65 | MILESTONE_DESCRIPTION: ${{ github.event.milestone.description }} 66 | CHANGELOG: ${{ needs.generate-changelog.outputs.changelog }} 67 | - name: Create Reference Release with Changelog 68 | uses: fleskesvor/create-release@feature/support-target-commitish 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | with: 72 | tag_name: ${{ env.MILESTONE }} 73 | release_name: ${{ env.MILESTONE }} 74 | body: ${{ steps.releasemessage.outputs.release_message }} 75 | draft: false 76 | prerelease: false 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Cees-Jan Kiewiet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactPHP Child Process Messenger 2 | 3 | [![Continuous Integration](https://github.com/WyriHaximus/reactphp-child-process-messenger/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/WyriHaximus/reactphp-child-process-messenger/actions/workflows/ci.yml) 4 | [![Latest Stable Version](https://poser.pugx.org/WyriHaximus/react-child-process-messenger/v/stable.png)](https://packagist.org/packages/WyriHaximus/react-child-process-messenger) 5 | [![Total Downloads](https://poser.pugx.org/WyriHaximus/react-child-process-messenger/downloads.png)](https://packagist.org/packages/WyriHaximus/react-child-process-messenger) 6 | [![Code Coverage](https://scrutinizer-ci.com/g/WyriHaximus/reactphp-child-process-messenger/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/WyriHaximus/reactphp-child-process-messenger/?branch=master) 7 | [![License](https://poser.pugx.org/WyriHaximus/react-child-process-messenger/license.png)](https://packagist.org/packages/wyrihaximus/react-child-process-messenger) 8 | 9 | Plain messages and RPC style wrapper around [`react/child-process`](https://github.com/reactphp/child-process). For pooling messengers take a look at [`wyrihaximus/react-child-process-pool`](https://github.com/WyriHaximus/reactphp-child-process-pool) 10 | 11 | ### Installation ### 12 | 13 | To install via [Composer](http://getcomposer.org/), use the command below, it will automatically detect the latest version and bind it with `~`. 14 | 15 | ``` 16 | composer require wyrihaximus/react-child-process-messenger 17 | ``` 18 | 19 | ## Hassle less Example ## 20 | 21 | While this package supports several ways of setting up communication between parent and child the simplest way is to create class implementing `WyriHaximus\React\ChildProcess\Messenger\ChildInterface`. Up on calling `create` everything is set up and created to handle supported `RPC`'s and messages. 22 | 23 | ```php 24 | registerRpc('example', function (Payload $payload) { 37 | return resolve($payload->getPayload()); 38 | }); 39 | } 40 | } 41 | ``` 42 | 43 | On the parent side you only need to call to spawn a child running that class: 44 | 45 | ```php 46 | MessengerFactory::parentFromClass('ExampleChild', $loop)->then(static function (Messenger $messenger): void { 47 | $messenger->rpc(/* etc etc */); 48 | }); 49 | ``` 50 | 51 | ## More Examples ## 52 | 53 | For both message and RPC examples see the [examples](https://github.com/WyriHaximus/reactphp-child-process-messenger/tree/master/examples) directory 54 | 55 | ## Contributing ## 56 | 57 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 58 | 59 | ## License ## 60 | 61 | Copyright 2021 [Cees-Jan Kiewiet](http://wyrihaximus.net/) 62 | 63 | Permission is hereby granted, free of charge, to any person 64 | obtaining a copy of this software and associated documentation 65 | files (the "Software"), to deal in the Software without 66 | restriction, including without limitation the rights to use, 67 | copy, modify, merge, publish, distribute, sublicense, and/or sell 68 | copies of the Software, and to permit persons to whom the 69 | Software is furnished to do so, subject to the following 70 | conditions: 71 | 72 | The above copyright notice and this permission notice shall be 73 | included in all copies or substantial portions of the Software. 74 | 75 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 76 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 77 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 78 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 79 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 80 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 81 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 82 | OTHER DEALINGS IN THE SOFTWARE. 83 | -------------------------------------------------------------------------------- /benchmark/memory.php: -------------------------------------------------------------------------------- 1 | then(function (Messenger $messenger) use ($loop) { 32 | $messenger->on('error', function ($e) { 33 | echo 'Error: ', \var_export($e, true), PHP_EOL; 34 | }); 35 | 36 | $i = 0; 37 | $loop->addPeriodicTimer(0.00001, function (TimerInterface $timer) use (&$i, $messenger, $loop) { 38 | if ($i >= I) { 39 | $loop->cancelTimer($timer); 40 | $messenger->softTerminate(); 41 | 42 | show_memory('Completed messaging'); 43 | 44 | $loop->addTimer(5, function () { 45 | }); 46 | 47 | return; 48 | } 49 | 50 | $messenger->rpc(MessagesFactory::rpc('return', [ 51 | 'i' => $i, 52 | 'time' => \time(), 53 | ])); 54 | 55 | $i++; 56 | }); 57 | })->done(); 58 | 59 | $loop->run(); 60 | 61 | show_memory('Done'); 62 | 63 | unset($loop); 64 | $loop = null; 65 | 66 | show_memory('Removed loop'); 67 | 68 | $cycles = \gc_collect_cycles(); 69 | 70 | show_memory('gc_collect_cycles: ' . $cycles); 71 | -------------------------------------------------------------------------------- /bin/child-process.source: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | "${PHP_INI_SCANDIR}/travis.ini" 18 | echo "Added php.ini from ${ADDITIONAL_PHP_INI} to ${PHP_INI_SCANDIR}/travis.ini" 19 | 20 | elif [[ "${_PHP}" == hhv* ]]; then 21 | echo "--Copy file for HHVM @ OSX--" 22 | fi 23 | 24 | elif [[ "${TRAVIS_OS_NAME}" == "linux" ]]; then 25 | if [[ "${TRAVIS_PHP_VERSION}" == php* ]]; then 26 | phpenv config-add "${ADDITIONAL_PHP_INI}" 27 | echo "Added php.ini from ${ADDITIONAL_PHP_INI} to phpenv" 28 | 29 | elif [[ "${TRAVIS_PHP_VERSION}" == hhv* ]]; then 30 | cat build/.travis.php.ini >> /etc/hhvm/php.ini 31 | echo "Added php.ini content from ${ADDITIONAL_PHP_INI} to /etc/hhvm/php.ini" 32 | fi 33 | fi 34 | -------------------------------------------------------------------------------- /build/handle_brew_pkg.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ "$#" -eq 1 ]]; then 4 | echo "Handling \"$1\" brew package..." 5 | else 6 | echo "Brew failed - invalid $0 call" 7 | exit 1; 8 | fi 9 | 10 | if [[ $(brew ls --versions "$1") ]]; then 11 | if brew outdated "$1"; then 12 | echo "Package upgrade is not required, skipping" 13 | else 14 | echo "Updating package..."; 15 | brew upgrade "$1" 16 | if [ $? -ne 0 ]; then 17 | echo "Upgrade failed" 18 | exit 1 19 | fi 20 | fi 21 | else 22 | echo "Package not available - installing..." 23 | brew install "$1" 24 | if [ $? -ne 0 ]; then 25 | echo "Install failed" 26 | exit 1 27 | fi 28 | fi 29 | 30 | echo "Linking installed package..." 31 | brew link "$1" 32 | -------------------------------------------------------------------------------- /build/osx_install_composer.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" 3 | php -r "if (hash_file('SHA384', 'composer-setup.php') === '070854512ef404f16bac87071a6db9fd9721da1684cd4589b1196c3faf71b9a2682e2311b36a5079825e155ac7ce150d') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" 4 | sudo php composer-setup.php --install-dir=/usr/local/bin/ --filename=composer 5 | php -r "unlink('composer-setup.php');" 6 | -------------------------------------------------------------------------------- /build/prepare_osx_env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Here's the OSX environment:" 4 | sw_vers 5 | brew --version 6 | 7 | echo "Updating brew..." 8 | brew update 9 | 10 | if [[ "${_PHP}" == "hhvm" ]]; then 11 | echo "Adding brew HHVM dependencies..." 12 | brew tap hhvm/hhvm 13 | 14 | else 15 | echo "Adding brew PHP dependencies..." 16 | brew tap homebrew/dupes 17 | brew tap homebrew/versions 18 | brew tap homebrew/homebrew-php 19 | fi 20 | -------------------------------------------------------------------------------- /composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist" : [ 3 | "null", "true", "false", 4 | "static", "self", "parent", 5 | "array", "string", "int", "float", "bool", "iterable", "callable", "void", "object", 6 | "validate_array", "throwable_decode", "throwable_encode", "throwable_json_decode", "throwable_json_encode" 7 | ], 8 | "php-core-extensions" : [ 9 | "Core", 10 | "date", 11 | "pcre", 12 | "Phar", 13 | "Reflection", 14 | "SPL", 15 | "standard" 16 | ], 17 | "scan-files" : [] 18 | } 19 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wyrihaximus/react-child-process-messenger", 3 | "description": "Messenger decorator for react/child-process", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Cees-Jan Kiewiet", 8 | "email": "ceesjank@gmail.com", 9 | "homepage": "http://wyrihaximus.net/" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8 || ^7.4", 14 | "ext-hash": "^8 || ^7.4", 15 | "ext-json": "^8 || ^7.4", 16 | "cakephp/utility": "^4.2.4", 17 | "doctrine/inflector": "^2.0.3", 18 | "evenement/evenement": "^3.0.1", 19 | "paragonie/random_compat": "^9.0 || ^2.0", 20 | "react/child-process": "^0.6.2", 21 | "react/event-loop": "^1.2", 22 | "react/promise": "^2.8", 23 | "react/promise-stream": "^1.2", 24 | "react/promise-timer": "^1.6", 25 | "react/socket": "^1.9", 26 | "symfony/polyfill-php81": "^1.23", 27 | "thecodingmachine/safe": "^1.3.3 || ^2.0 || ^3.0", 28 | "wyrihaximus/composer-update-bin-autoload-path": "^1.1.1", 29 | "wyrihaximus/constants": "^1.6", 30 | "wyrihaximus/file-descriptors": "^1.1", 31 | "wyrihaximus/json-throwable": "^4.2.0", 32 | "wyrihaximus/ticking-promise": "^3" 33 | }, 34 | "require-dev": { 35 | "phpstan/phpstan-phpunit": "^0.12.22", 36 | "wyrihaximus/async-test-utilities": "^4.2.2" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "WyriHaximus\\React\\ChildProcess\\Messenger\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "WyriHaximus\\React\\Tests\\ChildProcess\\Messenger\\": "tests/" 46 | }, 47 | "files": [ 48 | "examples/ExamplesChildProcess.php" 49 | ] 50 | }, 51 | "config": { 52 | "allow-plugins": { 53 | "dealerdirect/phpcodesniffer-composer-installer": true, 54 | "infection/extension-installer": true, 55 | "ergebnis/composer-normalize": true, 56 | "icanhazstring/composer-unused": true, 57 | "wyrihaximus/composer-update-bin-autoload-path": true 58 | }, 59 | "platform": { 60 | "php": "7.4.7" 61 | }, 62 | "sort-packages": true 63 | }, 64 | "extra": { 65 | "unused": [ 66 | "php", 67 | "react/promise-stream", 68 | "symfony/polyfill-php81" 69 | ], 70 | "wyrihaximus": { 71 | "bin-autoload-path-update": [ 72 | "bin/child-process" 73 | ] 74 | } 75 | }, 76 | "scripts": { 77 | "post-install-cmd": [ 78 | "composer normalize" 79 | ], 80 | "post-update-cmd": [ 81 | "composer normalize" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /etc/qa/composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist" : [ 3 | "null", "true", "false", 4 | "static", "self", "parent", 5 | "array", "string", "int", "float", "bool", "iterable", "callable", "void", "object", 6 | "Safe\\date", "WyriHaximus\\Constants\\Boolean\\FALSE_", "WyriHaximus\\Constants\\Boolean\\TRUE_", 7 | "WyriHaximus\\Constants\\Numeric\\ONE_FLOAT" 8 | ], 9 | "php-core-extensions" : [ 10 | "Core", 11 | "date", 12 | "pcre", 13 | "Phar", 14 | "Reflection", 15 | "SPL", 16 | "standard" 17 | ], 18 | "scan-files" : [] 19 | } 20 | -------------------------------------------------------------------------------- /etc/qa/phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ../../src 10 | ../../tests 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /etc/qa/phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | excludes_analyse: 3 | - tests/*Exception.php 4 | - tests/MissingAttributes.php 5 | ergebnis: 6 | classesAllowedToBeExtended: 7 | - Exception 8 | - WyriHaximus\TestUtilities\TestCase 9 | 10 | includes: 11 | - ../../vendor/wyrihaximus/test-utilities/rules.neon 12 | -------------------------------------------------------------------------------- /etc/qa/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ../../tests/ 6 | 7 | 8 | 9 | 10 | ../../src/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /etc/qa/psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 120, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "./var/infection.log", 10 | "summary": "./var/infection-summary.log", 11 | "json": "./var/infection.json", 12 | "perMutator": "./var/infection-per-mutator.md" 13 | }, 14 | "mutators": { 15 | "@default": false 16 | }, 17 | "phpUnit": { 18 | "configDir": "./etc/qa/" 19 | } 20 | } -------------------------------------------------------------------------------- /src/ChildInterface.php: -------------------------------------------------------------------------------- 1 | toArray())); 17 | } 18 | 19 | public static function decode(string $argv): Options 20 | { 21 | return new Options(...json_decode(base64_decode($argv, true))); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ChildProcess/DoesNotImplementChildInterfaceException.php: -------------------------------------------------------------------------------- 1 | class = $class; 20 | 21 | return $self; 22 | } 23 | 24 | public function class(): string 25 | { 26 | return $this->class; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ChildProcess/Factory.php: -------------------------------------------------------------------------------- 1 | then(static function (Messenger $messenger) use ($loop): void { 19 | Process::create($loop, $messenger); 20 | })->then(null, static function () use ($loop, &$exitCode): void { 21 | $loop->stop(); 22 | $exitCode = 1; 23 | }); 24 | 25 | $loop->run(); 26 | 27 | return $exitCode; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ChildProcess/Options.php: -------------------------------------------------------------------------------- 1 | random = $random; 16 | $this->address = $address; 17 | $this->connectTimeout = $connectTimeout; 18 | } 19 | 20 | public function random(): string 21 | { 22 | return $this->random; 23 | } 24 | 25 | public function address(): string 26 | { 27 | return $this->address; 28 | } 29 | 30 | public function connectTimeout(): int 31 | { 32 | return $this->connectTimeout; 33 | } 34 | 35 | /** 36 | * @return array 37 | */ 38 | public function toArray(): array 39 | { 40 | return [ 41 | $this->random, 42 | $this->address, 43 | $this->connectTimeout, 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ChildProcess/Process.php: -------------------------------------------------------------------------------- 1 | loop = $loop; 27 | $this->messenger = $messenger; 28 | $this->messenger->registerRpc( 29 | MessengerFactory::PROCESS_REGISTER, 30 | function (Payload $payload): PromiseInterface { 31 | /** 32 | * @psalm-suppress PossiblyNullArgument 33 | */ 34 | if (! is_subclass_of($payload['className'], ChildInterface::class)) { 35 | throw DoesNotImplementChildInterfaceException::create($payload['className'] ?? ''); 36 | } 37 | 38 | ($payload['className'])::create($this->messenger, $this->loop); 39 | $this->deregisterRpc(); 40 | 41 | return resolve([]); 42 | } 43 | ); 44 | } 45 | 46 | public static function create(LoopInterface $loop, Messenger $messenger): void 47 | { 48 | $reject = static function (Throwable $exeption) use ($loop): void { 49 | // $messenger->error(MessagesFactory::error($exeption->getFile())); 50 | $loop->addTimer(1, static function () use ($loop): void { 51 | $loop->stop(); 52 | }); 53 | }; 54 | 55 | try { 56 | new Process($loop, $messenger); 57 | } catch (Throwable $throwable) { /** @phpstan-ignore-line */ 58 | $reject($throwable); 59 | } 60 | } 61 | 62 | private function deregisterRpc(): void 63 | { 64 | $this->loop->futureTick(function (): void { 65 | $this->messenger->deregisterRpc(MessengerFactory::PROCESS_REGISTER); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/CommunicationWithProcessUnexpectedEndException.php: -------------------------------------------------------------------------------- 1 | $options 50 | */ 51 | public static function parent( 52 | Process $process, 53 | LoopInterface $loop, 54 | array $options = [] 55 | ): Promise\PromiseInterface { 56 | return new Promise\Promise(static function (callable $resolve, callable $reject) use ($process, $loop, $options): void { 57 | $server = new SocketServer('127.0.0.1:0', [], $loop); 58 | 59 | $options['random'] = bin2hex(random_bytes(32)); 60 | $options['address'] = (string) $server->getAddress(); 61 | $options = new Options($options['random'], $options['address'], $options['connect-timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT); 62 | $argvString = escapeshellarg(ArgvEncoder::encode($options)); 63 | $process = new Process($process->getCommand() . ' ' . $argvString); 64 | 65 | self::startParent($process, $server, $loop, $options)->then($resolve, $reject); 66 | }); 67 | } 68 | 69 | /** 70 | * @param array $options 71 | * 72 | * @return Promise\PromiseInterface 73 | * 74 | * @psalm-suppress TooManyTemplateParams 75 | * @phpstan-ignore-next-line 76 | */ 77 | public static function parentFromClass( 78 | string $class, 79 | LoopInterface $loop, 80 | array $options = [] 81 | ): Promise\PromiseInterface { 82 | if (! is_subclass_of($class, ChildInterface::class)) { 83 | /** @phpstan-ignore-next-line */ 84 | throw new Exception('Given class doesn\'t implement ChildInterface'); 85 | } 86 | 87 | return new Promise\Promise(static function (callable $resolve, callable $reject) use ($class, $loop, $options): void { 88 | $template = '%s'; 89 | if (array_key_exists('cmdTemplate', $options)) { 90 | $template = $options['cmdTemplate']; 91 | unset($options['cmdTemplate']); 92 | } 93 | 94 | $fds = []; 95 | if (StaticConfig::shouldListFileDescriptors() && DIRECTORY_SEPARATOR !== '\\') { 96 | if (array_key_exists('fileDescriptorLister', $options) && $options['fileDescriptorLister'] instanceof ListerInterface) { 97 | $fileDescriptorLister = $options['fileDescriptorLister']; 98 | unset($options['fileDescriptorLister']); 99 | } else { 100 | $fileDescriptorLister = FileDescriptorsFactory::create(); 101 | } 102 | 103 | foreach ($fileDescriptorLister->list() as $id) { 104 | $fds[(int) $id] = ['file', '/dev/null', 'r']; 105 | } 106 | } 107 | 108 | $server = new SocketServer('127.0.0.1:0', [], $loop); 109 | $connectTimeout = $options['connect-timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT; 110 | $options = new Options(bin2hex(random_bytes(32)), (string) $server->getAddress(), $options['connect-timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT); 111 | 112 | $phpBinary = escapeshellarg(PHP_BINARY . (PHP_SAPI === 'phpdbg' ? ' -qrr --' : '')); 113 | $childProcessPath = escapeshellarg(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'child-process'); 114 | $argvString = escapeshellarg(ArgvEncoder::encode($options)); 115 | $command = $phpBinary . ' ' . $childProcessPath; 116 | 117 | $process = new Process( 118 | sprintf( 119 | $template, 120 | $command . ' ' . $argvString 121 | ), 122 | null, 123 | null, 124 | $fds 125 | ); 126 | 127 | futurePromise()->then(static function () use ($process, $server, $loop, $options, $connectTimeout): Promise\PromiseInterface { 128 | return Promise\Timer\timeout(self::startParent($process, $server, $loop, $options), $connectTimeout, $loop); 129 | })->then(static function (Messenger $messenger) use ($class): Promise\PromiseInterface { 130 | return $messenger->rpc(MessengesFactory::rpc(Factory::PROCESS_REGISTER, ['className' => $class]))->then(static function () use ($messenger): Promise\PromiseInterface { 131 | return Promise\resolve($messenger); 132 | }); 133 | })->then($resolve, $reject); 134 | }); 135 | } 136 | 137 | /** 138 | * @phpstan-ignore-next-line 139 | */ 140 | public static function child(LoopInterface $loop, Options $options, ?callable $termiteCallable = null): Promise\PromiseInterface 141 | { 142 | $connectTimeout = $options->connectTimeout(); 143 | 144 | return (new Connector($loop, ['timeout' => $connectTimeout]))->connect($options->address())->then(static function (ConnectionInterface $connection) use ($options, $loop, $connectTimeout): Promise\PromiseInterface { 145 | return new Promise\Promise(static function (callable $resolve, callable $reject) use ($connection, $options, $loop, $connectTimeout): void { 146 | Promise\Timer\timeout(Promise\Stream\first($connection), $connectTimeout, $loop)->then(static function (string $chunk) use ($resolve, $reject, $connection, $options, $loop): void { 147 | [$confirmation] = explode(PHP_EOL, $chunk); 148 | if ($confirmation === 'syn') { 149 | $connection->write('ack' . PHP_EOL); 150 | $resolve(new Messenger($connection, $options)); 151 | $connection->on('close', [$loop, 'stop']); 152 | $connection->on('error', [$loop, 'stop']); 153 | 154 | return; 155 | } 156 | 157 | $reject(new RuntimeException('Handshake SYN failed')); 158 | }, $reject); 159 | $connection->write(hash_hmac('sha512', $options->address(), $options->random()) . PHP_EOL); 160 | }); 161 | })->then(static function (Messenger $messenger) use ($loop, $termiteCallable): Messenger { 162 | if ($termiteCallable === null) { 163 | $termiteCallable = static function () use ($loop): void { 164 | $loop->addTimer( 165 | self::TERMINATE_TIMEOUT, 166 | [ 167 | $loop, 168 | 'stop', 169 | ] 170 | ); 171 | }; 172 | } 173 | 174 | $messenger->registerRpc( 175 | Messenger::TERMINATE_RPC, 176 | static function (Payload $payload, Messenger $messenger) use ($termiteCallable): Promise\PromiseInterface { 177 | $messenger->emit('terminate', [$messenger]); 178 | $termiteCallable(); 179 | 180 | return Promise\resolve([]); 181 | } 182 | ); 183 | 184 | return $messenger; 185 | }); 186 | } 187 | 188 | private static function startParent( 189 | Process $process, 190 | SocketServer $server, 191 | LoopInterface $loop, 192 | Options $options 193 | ): Promise\PromiseInterface { 194 | return (new Promise\Promise(static function (callable $resolve, callable $reject) use ($process, $server, $loop, $options): void { 195 | $server->on( 196 | 'connection', 197 | static function (ConnectionInterface $connection) use ($server, $resolve, $reject, $options): void { 198 | Promise\Stream\first($connection)->then(static function (string $chunk) use ($options, $connection): Promise\PromiseInterface { 199 | [$confirmation] = explode(PHP_EOL, $chunk); 200 | if ($confirmation === hash_hmac('sha512', $options->address(), $options->random())) { 201 | $connection->write('syn' . PHP_EOL); 202 | 203 | return Promise\Stream\first($connection); 204 | } 205 | 206 | return Promise\reject(new RuntimeException('Signature mismatch')); 207 | })->then(static function (string $chunk) use ($options, $connection): Promise\PromiseInterface { 208 | [$confirmation] = explode(PHP_EOL, $chunk); 209 | if ($confirmation === 'ack') { 210 | return Promise\resolve(new Messenger($connection, $options)); 211 | } 212 | 213 | return Promise\reject(new RuntimeException('Handshake failed')); 214 | })->then(static function (MessengerInterface $messenger) use ($server, $resolve): void { 215 | $server->close(); 216 | $server->removeAllListeners('connection'); 217 | $resolve($messenger); 218 | }, static function (Throwable $throwable) use ($server, $reject): void { 219 | $server->close(); 220 | $server->removeAllListeners(); 221 | $reject($throwable); 222 | }); 223 | } 224 | ); 225 | $server->on('error', static function (Throwable $et) use ($reject): void { 226 | $reject($et); 227 | }); 228 | 229 | $process->start($loop); 230 | }, static function () use ($server, $process): void { 231 | $server->close(); 232 | $server->removeAllListeners(); 233 | $process->terminate(); 234 | }))->then(static function (Messenger $messenger) use ($loop, $process): Messenger { 235 | $loop->addPeriodicTimer(self::INTERVAL, static function (TimerInterface $timer) use ($messenger, $loop, $process): void { 236 | if ($process->isRunning()) { 237 | return; 238 | } 239 | 240 | $loop->cancelTimer($timer); 241 | 242 | $exitCode = $process->getExitCode(); 243 | if ($exitCode === 0) { 244 | return; 245 | } 246 | 247 | if ($exitCode === null) { 248 | return; 249 | } 250 | 251 | $messenger->crashed($exitCode); 252 | }); 253 | 254 | return $messenger; 255 | }); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/Messages/ActionableMessageInterface.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 19 | } 20 | 21 | public function getPayload(): Throwable 22 | { 23 | return $this->payload; 24 | } 25 | 26 | /** 27 | * @return array> 28 | */ 29 | public function jsonSerialize(): array 30 | { 31 | return [ 32 | 'type' => 'error', 33 | 'payload' => throwable_encode($this->payload), 34 | ]; 35 | } 36 | 37 | public function handle(object $bindTo, string $source): void 38 | { 39 | $cb = function (Throwable $payload): void { 40 | /** 41 | * @psalm-suppress UndefinedMethod 42 | */ 43 | $this->emit('error', [ /** @phpstan-ignore-line */ 44 | $payload, 45 | $this, 46 | ]); 47 | }; 48 | $cb = $cb->bindTo($bindTo); 49 | /** 50 | * @psalm-suppress PossiblyInvalidFunctionCall 51 | */ 52 | $cb($this->payload); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Messages/Factory.php: -------------------------------------------------------------------------------- 1 | $lineOptions 20 | */ 21 | public static function fromLine(string $line, array $lineOptions): ActionableMessageInterface 22 | { 23 | $line = json_decode($line, true); 24 | self::$inflector ??= InflectorFactory::create()->build(); 25 | $method = self::$inflector->camelize($line['type']); 26 | if ($method === 'secure') { 27 | return static::secureFromLine($line, $lineOptions); 28 | } 29 | 30 | if ($method === 'message') { 31 | return static::messageFromLine($line); 32 | } 33 | 34 | if ($method === 'error') { 35 | return static::errorFromLine($line); 36 | } 37 | 38 | if ($method === 'rpc') { 39 | return static::rpcFromLine($line); 40 | } 41 | 42 | if ($method === 'rpcError') { 43 | return static::rpcErrorFromLine($line); 44 | } 45 | 46 | if ($method === 'rpcSuccess') { 47 | return static::rpcSuccessFromLine($line); 48 | } 49 | 50 | /** @phpstan-ignore-next-line */ 51 | throw new Exception('Unknown message type: ' . $line['type']); 52 | } 53 | 54 | /** 55 | * @param array $payload 56 | */ 57 | public static function message(array $payload = []): Message 58 | { 59 | return new Message(new Payload($payload)); 60 | } 61 | 62 | public static function error(Throwable $payload): Error 63 | { 64 | return new Error($payload); 65 | } 66 | 67 | /** 68 | * @param array $payload 69 | * @param mixed $uniqid 70 | */ 71 | public static function rpc(string $target, array $payload = [], $uniqid = ''): Rpc 72 | { 73 | return new Rpc($target, new Payload($payload), $uniqid); 74 | } 75 | 76 | public static function rpcError(string $uniqid, Throwable $et): RpcError 77 | { 78 | return new RpcError($uniqid, $et); 79 | } 80 | 81 | /** 82 | * @param array $payload 83 | */ 84 | public static function rpcSuccess(string $uniqid, array $payload = []): RpcSuccess 85 | { 86 | return new RpcSuccess($uniqid, new Payload($payload)); 87 | } 88 | 89 | /** 90 | * @param array $line 91 | * @param array $lineOptions 92 | */ 93 | private static function secureFromLine(array $line, array $lineOptions): ActionableMessageInterface 94 | { 95 | return SecureLine::fromLine($line, $lineOptions); 96 | } 97 | 98 | /** 99 | * @param array $line 100 | */ 101 | private static function messageFromLine(array $line): Message 102 | { 103 | return static::message($line['payload']); 104 | } 105 | 106 | /** 107 | * @param array $line 108 | */ 109 | private static function errorFromLine(array $line): Error 110 | { 111 | return static::error(LineDecoder::decode($line['payload'])['throwable']); 112 | } 113 | 114 | /** 115 | * @param array $line 116 | */ 117 | private static function rpcFromLine(array $line): Rpc 118 | { 119 | return static::rpc($line['target'], $line['payload'], $line['uniqid']); 120 | } 121 | 122 | /** 123 | * @param array $line 124 | */ 125 | private static function rpcErrorFromLine(array $line): RpcError 126 | { 127 | return static::rpcError($line['uniqid'], LineDecoder::decode($line['payload'])['throwable']); 128 | } 129 | 130 | /** 131 | * @param array $line 132 | */ 133 | private static function rpcSuccessFromLine(array $line): RpcSuccess 134 | { 135 | return static::rpcSuccess($line['uniqid'], $line['payload']); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Messages/Line.php: -------------------------------------------------------------------------------- 1 | $options 17 | * 18 | * @phpstan-ignore-next-line 19 | */ 20 | public function __construct(ActionableMessageInterface $payload, array $options) 21 | { 22 | $this->payload = $payload; 23 | } 24 | 25 | public function __toString(): string 26 | { 27 | return json_encode($this->payload) . LineInterface::EOL; 28 | } 29 | 30 | public function getPayload(): JsonSerializable 31 | { 32 | return $this->payload; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Messages/LineDecoder.php: -------------------------------------------------------------------------------- 1 | $line 17 | * 18 | * @return array 19 | * 20 | * @throws ExceptionInterface 21 | * @throws ReflectionException 22 | */ 23 | public static function decode(array $line): array 24 | { 25 | if ($line[LineEncoder::META_KEY][LineEncoder::TYPE_KEY] === LineEncoder::TYPE_THROWABLE) { 26 | return ['throwable' => throwable_decode($line[LineEncoder::VALUE_KEY])]; 27 | } 28 | 29 | foreach ($line[LineEncoder::META_KEY][LineEncoder::THROWABLES_KEY] as $throwableKey) { 30 | $line[LineEncoder::VALUE_KEY][$throwableKey] = throwable_decode($line[LineEncoder::VALUE_KEY][$throwableKey]); 31 | } 32 | 33 | return Hash::expand($line[LineEncoder::VALUE_KEY]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Messages/LineEncoder.php: -------------------------------------------------------------------------------- 1 | |Throwable $line 23 | * 24 | * @return array 25 | */ 26 | public static function encode($line): array 27 | { 28 | if ($line instanceof Throwable) { 29 | return [ 30 | self::META_KEY => [ 31 | self::TYPE_KEY => self::TYPE_THROWABLE, 32 | ], 33 | self::VALUE_KEY => throwable_encode($line), 34 | ]; 35 | } 36 | 37 | $throwables = []; 38 | $line = Hash::flatten($line); 39 | foreach ($line as $key => $value) { 40 | if (! ($value instanceof Throwable)) { 41 | continue; 42 | } 43 | 44 | $throwables[] = $key; 45 | $line[$key] = throwable_encode($value); 46 | } 47 | 48 | return [ 49 | self::META_KEY => [ 50 | self::TYPE_KEY => self::TYPE_ARRAY, 51 | self::THROWABLES_KEY => $throwables, 52 | ], 53 | self::VALUE_KEY => $line, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Messages/LineInterface.php: -------------------------------------------------------------------------------- 1 | $options 13 | */ 14 | public function __construct(ActionableMessageInterface $line, array $options); 15 | 16 | public function __toString(): string; 17 | } 18 | -------------------------------------------------------------------------------- /src/Messages/Message.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 16 | } 17 | 18 | public function getPayload(): Payload 19 | { 20 | return $this->payload; 21 | } 22 | 23 | /** 24 | * @return array 25 | */ 26 | public function jsonSerialize(): array 27 | { 28 | return [ 29 | 'type' => 'message', 30 | 'payload' => $this->payload, 31 | ]; 32 | } 33 | 34 | public function handle(object $bindTo, string $source): void 35 | { 36 | $cb = function (Payload $payload): void { 37 | /** 38 | * @psalm-suppress UndefinedMethod 39 | */ 40 | $this->emit('message', [ /** @phpstan-ignore-line */ 41 | $payload, 42 | $this, 43 | ]); 44 | }; 45 | $cb = $cb->bindTo($bindTo); 46 | /** 47 | * @psalm-suppress PossiblyInvalidFunctionCall 48 | */ 49 | $cb($this->payload); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Messages/Payload.php: -------------------------------------------------------------------------------- 1 | */ 18 | protected array $payload = []; 19 | 20 | /** 21 | * @param array $payload 22 | * 23 | * @phpstan-ignore-next-line 24 | */ 25 | public function __construct(array $payload = []) /** @phpstan-ignore-line */ 26 | { 27 | $this->payload = $payload; 28 | } 29 | 30 | /** 31 | * @return array 32 | */ 33 | public function getPayload(): array 34 | { 35 | return $this->payload; 36 | } 37 | 38 | /** 39 | * @return array 40 | */ 41 | public function jsonSerialize(): array 42 | { 43 | return $this->payload; 44 | } 45 | 46 | /** 47 | * @param mixed|null $offset 48 | * @param mixed $value 49 | */ 50 | public function offsetSet($offset, $value): void 51 | { 52 | if ($offset === null) { 53 | $this->payload[] = $value; 54 | } else { 55 | $this->payload[$offset] = $value; 56 | } 57 | } 58 | 59 | /** 60 | * @param mixed $offset 61 | */ 62 | public function offsetExists($offset): bool 63 | { 64 | /** @phpstan-ignore-next-line */ 65 | return isset($this->payload[$offset]); 66 | } 67 | 68 | /** 69 | * @param mixed $offset 70 | */ 71 | public function offsetUnset($offset): void 72 | { 73 | unset($this->payload[$offset]); 74 | } 75 | 76 | /** 77 | * @param mixed $offset 78 | * 79 | * @return mixed|null 80 | */ 81 | #[ReturnTypeWillChange] 82 | public function offsetGet($offset) 83 | { 84 | return $this->payload[$offset] ?? null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Messages/Rpc.php: -------------------------------------------------------------------------------- 1 | target = $target; 26 | $this->payload = $payload; 27 | $this->uniqid = $uniqid; 28 | } 29 | 30 | public function getPayload(): Payload 31 | { 32 | return $this->payload; 33 | } 34 | 35 | public function setUniqid(string $uniqid): self 36 | { 37 | return new self($this->target, $this->payload, $uniqid); 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function jsonSerialize(): array 44 | { 45 | return [ 46 | 'type' => 'rpc', 47 | 'uniqid' => $this->uniqid, 48 | 'target' => $this->target, 49 | 'payload' => $this->payload, 50 | ]; 51 | } 52 | 53 | public function handle(object $bindTo, string $source): void 54 | { 55 | $cb = Closure::fromCallable(function (string $target, Payload $payload, string $uniqid): void { 56 | /** 57 | * @psalm-suppress UndefinedMethod 58 | */ 59 | if (! $this->hasRpc($target)) { /** @phpstan-ignore-line */ 60 | $this->write($this->createLine(Factory::rpcError($uniqid, new Exception(sprintf('Rpc target <%s> doesn\'t exist', $target))))); /** @phpstan-ignore-line */ 61 | 62 | return; 63 | } 64 | 65 | /** 66 | * @psalm-suppress UndefinedMethod 67 | */ 68 | $this->callRpc($target, $payload)->done( /** @phpstan-ignore-line */ 69 | function (array $payload) use ($uniqid): void { 70 | /** 71 | * @psalm-suppress UndefinedMethod 72 | */ 73 | $this->write($this->createLine(Factory::rpcSuccess($uniqid, $payload))); /** @phpstan-ignore-line */ 74 | }, 75 | function (Throwable $error) use ($uniqid): void { 76 | /** 77 | * @psalm-suppress UndefinedMethod 78 | */ 79 | $this->write($this->createLine(Factory::rpcError($uniqid, $error))); /** @phpstan-ignore-line */ 80 | } 81 | ); 82 | }); 83 | $cb = $cb->bindTo($bindTo); 84 | /** 85 | * @psalm-suppress PossiblyInvalidFunctionCall 86 | */ 87 | $cb($this->target, $this->payload, $this->uniqid); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Messages/RpcError.php: -------------------------------------------------------------------------------- 1 | uniqid = $uniqid; 22 | $this->payload = $payload; 23 | } 24 | 25 | /** 26 | * @return Exception|Throwable 27 | */ 28 | public function getPayload() 29 | { 30 | return $this->payload; 31 | } 32 | 33 | /** 34 | * @return array 35 | */ 36 | public function jsonSerialize(): array 37 | { 38 | return [ 39 | 'type' => 'rpc_error', 40 | 'uniqid' => $this->uniqid, 41 | 'payload' => LineEncoder::encode($this->payload), 42 | ]; 43 | } 44 | 45 | public function handle(MessengerInterface $bindTo, string $source): void 46 | { 47 | $cb = Closure::fromCallable(function (Throwable $payload, string $uniqid): void { 48 | /** 49 | * @psalm-suppress UndefinedMethod 50 | */ 51 | $this->getOutstandingCall($uniqid)->reject($payload); /** @phpstan-ignore-line */ 52 | }); 53 | $cb = $cb->bindTo($bindTo); 54 | /** 55 | * @psalm-suppress PossiblyInvalidFunctionCall 56 | */ 57 | $cb($this->payload, $this->uniqid); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Messages/RpcSuccess.php: -------------------------------------------------------------------------------- 1 | uniqid = $uniqid; 20 | $this->payload = $payload; 21 | } 22 | 23 | public function getPayload(): Payload 24 | { 25 | return $this->payload; 26 | } 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function jsonSerialize(): array 32 | { 33 | return [ 34 | 'type' => 'rpc_success', 35 | 'uniqid' => $this->uniqid, 36 | 'payload' => $this->payload, 37 | ]; 38 | } 39 | 40 | public function handle(MessengerInterface $bindTo, string $source): void 41 | { 42 | $cb = Closure::fromCallable(function (Payload $payload, string $uniqid): void { 43 | /** 44 | * @psalm-suppress UndefinedMethod 45 | */ 46 | $this->getOutstandingCall($uniqid)->resolve($payload); /** @phpstan-ignore-line */ 47 | }); 48 | $cb = $cb->bindTo($bindTo); 49 | /** 50 | * @psalm-suppress PossiblyInvalidFunctionCall 51 | */ 52 | $cb($this->payload, $this->uniqid); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Messages/SecureLine.php: -------------------------------------------------------------------------------- 1 | $options 23 | */ 24 | public function __construct(ActionableMessageInterface $line, array $options) 25 | { 26 | $this->line = $line; 27 | $this->key = $options['key']; 28 | } 29 | 30 | public function __toString(): string 31 | { 32 | $line = json_encode($this->line); 33 | 34 | return json_encode([ 35 | 'type' => 'secure', 36 | 'line' => $line, 37 | 'signature' => base64_encode(static::sign($line, $this->key)), 38 | ]) . LineInterface::EOL; 39 | } 40 | 41 | /** 42 | * @param array $line 43 | * @param array $lineOptions 44 | * 45 | * @throws Exception 46 | */ 47 | public static function fromLine(array $line, array $lineOptions): ActionableMessageInterface 48 | { 49 | if (static::validate(base64_decode($line['signature'], true), $line['line'], $lineOptions['key'])) { 50 | return Factory::fromLine($line['line'], $lineOptions); 51 | } 52 | 53 | /** @phpstan-ignore-next-line */ 54 | throw new Exception('Signature mismatch!'); 55 | } 56 | 57 | private static function sign(string $line, string $key): string 58 | { 59 | return hash_hmac('sha256', $line, $key, true); 60 | } 61 | 62 | private static function validate(string $signature, string $line, string $key): bool 63 | { 64 | return hash_equals($signature, static::sign($line, $key)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Messenger.php: -------------------------------------------------------------------------------- 1 | */ 41 | protected array $rpcs = []; 42 | 43 | /** @var array */ 44 | protected array $options = []; 45 | 46 | protected string $buffer = ''; 47 | 48 | /** @var array> */ 49 | protected array $defaultOptions = [ 50 | 'lineClass' => Line::class, 51 | 'messageFactoryClass' => MessagesFactory::class, 52 | 'lineOptions' => [], 53 | ]; 54 | 55 | public function __construct( 56 | ConnectionInterface $connection, 57 | Options $options 58 | ) { 59 | $this->connection = $connection; 60 | 61 | /** 62 | * @psalm-suppress InvalidMethodCall 63 | */ 64 | $this->options = $this->defaultOptions + $options->toArray(); 65 | 66 | $this->outstandingRpcCalls = new OutstandingCalls(); 67 | 68 | $this->connection->on('data', function (string $data): void { 69 | $this->buffer .= $data; 70 | $this->emit('data', [$data]); 71 | $this->handleData(); 72 | }); 73 | $this->connection->on('close', function (): void { 74 | $calls = $this->outstandingRpcCalls->getCalls(); 75 | if (count($calls) === 0) { 76 | return; 77 | } 78 | 79 | $error = new CommunicationWithProcessUnexpectedEndException(); 80 | $this->emit('error', [$error, $this]); 81 | foreach ($calls as $call) { 82 | $call->reject($error); 83 | } 84 | }); 85 | } 86 | 87 | public function registerRpc(string $target, callable $listener): void 88 | { 89 | $this->rpcs[$target] = $listener; 90 | } 91 | 92 | public function deregisterRpc(string $target): void 93 | { 94 | unset($this->rpcs[$target]); 95 | } 96 | 97 | public function hasRpc(string $target): bool 98 | { 99 | return array_key_exists($target, $this->rpcs); 100 | } 101 | 102 | public function callRpc(string $target, Payload $payload): PromiseInterface 103 | { 104 | try { 105 | return $this->rpcs[$target]($payload, $this); 106 | 107 | /** @phpstan-ignore-next-line */ 108 | } catch (Throwable $exception) { 109 | return reject($exception); 110 | } 111 | } 112 | 113 | public function message(Message $message): void 114 | { 115 | $this->write($this->createLine($message)); 116 | } 117 | 118 | public function error(Error $error): void 119 | { 120 | $this->write($this->createLine($error)); 121 | } 122 | 123 | public function getOutstandingCall(string $uniqid): OutstandingCallInterface 124 | { 125 | return $this->outstandingRpcCalls->getCall($uniqid); 126 | } 127 | 128 | public function rpc(Rpc $rpc): PromiseInterface 129 | { 130 | $callReference = $this->outstandingRpcCalls->newCall(function (): void {}); // phpcs:disabled 131 | 132 | $this->write($this->createLine($rpc->setUniqid($callReference->getUniqid()))); 133 | 134 | return $callReference->getDeferred()->promise(); 135 | } 136 | 137 | public function createLine(ActionableMessageInterface $line): string 138 | { 139 | $lineCLass = $this->options['lineClass']; 140 | 141 | /** 142 | * @psalm-suppress InvalidCast 143 | */ 144 | return (string) new $lineCLass($line, $this->options['lineOptions']); 145 | } 146 | 147 | public function softTerminate(): PromiseInterface 148 | { 149 | return $this->rpc(MessagesFactory::rpc(self::TERMINATE_RPC)); 150 | } 151 | 152 | public function write(string $line): void 153 | { 154 | $this->connection->write($line); 155 | } 156 | 157 | /** 158 | * @internal 159 | */ 160 | public function crashed(int $exitCode): void 161 | { 162 | $this->emit('error', [new ProcessUnexpectedEndException($exitCode), $this]); 163 | } 164 | 165 | private function handleData(): void 166 | { 167 | if (strpos($this->buffer, LineInterface::EOL) === false) { 168 | return; 169 | } 170 | 171 | $messages = explode(LineInterface::EOL, $this->buffer); 172 | $this->buffer = array_pop($messages); 173 | $this->iterateMessages($messages); 174 | } 175 | 176 | /** 177 | * @param array $messages 178 | */ 179 | private function iterateMessages(array $messages): void 180 | { 181 | foreach ($messages as $message) { 182 | try { 183 | MessagesFactory::fromLine($message, [])->handle($this, 'source'); 184 | /** @phpstan-ignore-next-line */ 185 | } catch (Throwable $exception) { 186 | $this->emit('error', [$exception, $this]); 187 | } 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/MessengerInterface.php: -------------------------------------------------------------------------------- 1 | emit('error', [new ProcessUnexpectedEndException($exitCode), $this]); 44 | // } 45 | } 46 | -------------------------------------------------------------------------------- /src/OutstandingCall.php: -------------------------------------------------------------------------------- 1 | uniqid = $uniqid; 29 | $this->deferred = new Deferred($canceller); 30 | 31 | if (! is_callable($cleanup)) { 32 | $cleanup = static function (): void { 33 | }; 34 | } 35 | 36 | $this->cleanup = $cleanup; 37 | } 38 | 39 | /** 40 | * @return mixed 41 | */ 42 | public function getUniqid() 43 | { 44 | return $this->uniqid; 45 | } 46 | 47 | public function getDeferred(): Deferred 48 | { 49 | return $this->deferred; 50 | } 51 | 52 | /** 53 | * @param mixed $value 54 | */ 55 | public function resolve($value): void 56 | { 57 | $cleanup = $this->cleanup; 58 | $cleanup($this); 59 | 60 | $this->deferred->resolve($value); 61 | } 62 | 63 | /** 64 | * @param mixed $value 65 | */ 66 | public function reject($value): void 67 | { 68 | $cleanup = $this->cleanup; 69 | $cleanup($this); 70 | 71 | $this->deferred->reject($value); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/OutstandingCallInterface.php: -------------------------------------------------------------------------------- 1 | */ 18 | protected array $calls = []; 19 | 20 | public function newCall(Closure $canceller): OutstandingCall 21 | { 22 | $uniqid = $this->getNewUniqid(); 23 | 24 | $this->calls[$uniqid] = new OutstandingCall($uniqid, $canceller, function (OutstandingCall $call): void { 25 | unset($this->calls[$call->getUniqid()]); 26 | }); 27 | 28 | return $this->calls[$uniqid]; 29 | } 30 | 31 | public function getCall(string $uniqid): OutstandingCall 32 | { 33 | return $this->calls[$uniqid]; 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function getCalls(): array 40 | { 41 | return array_values($this->calls); 42 | } 43 | 44 | private function getNewUniqid(): string 45 | { 46 | do { 47 | $uniqid = (string) microtime(true) . '.' . bin2hex(random_bytes(4)); 48 | } while (array_key_exists($uniqid, $this->calls)); 49 | 50 | return $uniqid; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ProcessUnexpectedEndException.php: -------------------------------------------------------------------------------- 1 | registerRpc('return', static function (Payload $payload): PromiseInterface { 20 | return resolve($payload->getPayload()); 21 | }); 22 | $messenger->on('message', static function (Payload $payload) use ($messenger): void { 23 | $messenger->message(MessagesFactory::message($payload->getPayload())); 24 | }); 25 | } 26 | 27 | public static function create(Messenger $messenger, LoopInterface $loop): void 28 | { 29 | new static($messenger, $loop); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/StaticConfig.php: -------------------------------------------------------------------------------- 1 | getConstructor()->getParameters(); /** @phpstan-ignore-line */ 27 | if (! isset($arguments[self::EXPECTED_INDEX])) { /** @phpstan-ignore-line */ 28 | return $should = FALSE_; 29 | } 30 | 31 | return $should = ($arguments[self::EXPECTED_INDEX]->getName() === 'fds'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /var/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WyriHaximus/reactphp-child-process-messenger/937f23bebd38c95af54ec1be502bdcd7a58fdec2/var/.gitkeep --------------------------------------------------------------------------------