diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5c955a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + pull_request: + +jobs: + default: + name: Default suite (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + extensions: pcntl, posix, shmop + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Code style + run: composer cs-check + + - name: Default suite (base + working) + run: composer test + + container: + name: Container suite (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build test image (cached; pcntl/posix/shmop/swoole) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: tests/docker/Dockerfile + build-args: PHP_VERSION=${{ matrix.php }} + tags: wt-test:${{ matrix.php }} + load: true + cache-from: type=gha,scope=wt-${{ matrix.php }} + cache-to: type=gha,mode=max,scope=wt-${{ matrix.php }} + + - name: Run default + container suites + run: | + docker run --init --rm -v "$PWD":/app -w /app wt-test:${{ matrix.php }} bash -lc ' + composer install --no-interaction --no-progress + composer test + vendor/bin/phpunit --testsuite container + ' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..74af1cb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_Nothing yet._ + +## [2.0.0] - 2026-07-03 + +A ground-up redesign. `Thread` keeps its familiar Java-like API but is now a thin +facade over small, composable primitives you can build a pool/scheduler on. + +### Added +- **`Engine` abstraction** bound once via `Thread::bindEngine()`: + - `AdaptiveEngine` — self-configuring default (detects CLI/FPM binary, Swoole + runtime, and `WINTER_THREAD_SECRET`); + - `ManualEngine` — explicit, immutable withers for full control. +- **Parent-side primitives** for framework authors: `Launcher` / `CliLauncher`, + `ProcessHandle`, and the `LaunchSpec` DTO — drive processes without the `Thread` + facade. +- **Pluggable payload transports**: `PipeTransport`, `TempFileTransport` (Swoole-safe), + and `ShmTransport` (System V shared memory; needs `ext-shmop`). The engine + auto-selects a Swoole-safe transport under an active Swoole runtime. +- **Child-side `Runner`** (`AdaptiveRunner`), driven by the `wRunner` bootstrap and + deliberately independent of the parent `Engine`. +- **Non-blocking lifecycle**: `reap()`, `detach()`, `getExitCode()`, and a + zombie-safe destructor — the basis for polling many workers in one loop. +- **Detached mode** (`start(detached: true)`): `fork` + `setsid` for zombie-free + fire-and-forget under a long-lived parent. +- **Signed payloads** via `opis/closure` (HMAC), with the secret propagated to the + child through the environment (never argv). +- Full documentation set under [`docs/`](docs/README.md) and a two-tier test suite + (default + container) with CI across PHP 8.4 / 8.5. + +### Changed +- **Requires PHP >= 8.4** (`readonly` classes, first-class callable syntax). +- Deserialization now goes **exclusively** through `opis/closure` — native + `unserialize()` is never used. +- CLI arguments (binary/runner paths, metadata, per-run args, shm key) are uniformly + escaped with `escapeshellarg()`. + +### Security +- Signing secret is transmitted via the owner-only environment, never on the command + line. +- An ambient `WINTER_THREAD_SECRET` in the parent environment is neutralized for the + child when the engine is unsigned, preventing spurious "failed to deserialize" + rejections. + +[Unreleased]: https://github.com/flytachi/winter-thread/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/flytachi/winter-thread/releases/tag/v2.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cd71f89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to Winter Thread + +Thanks for helping improve Winter Thread! This guide covers local setup, the test +tiers, and the conventions the project follows. + +## Requirements + +- **PHP >= 8.4** with `ext-pcntl` and `ext-posix` (and `ext-shmop` if you touch the + shared-memory transport). +- Composer. +- A POSIX OS (Linux or macOS) — the engine relies on signals, `setsid`, and + `/proc`/`ps`. Windows is not supported. + +## Setup + +```bash +git clone https://github.com/flytachi/winter-thread +cd winter-thread +composer install +``` + +## Running the tests + +The suite has **two tiers** (see [docs/15 — Testing](docs/15-testing.md)). + +**Default tier** — runs on any machine; tests needing an absent extension self-skip: + +```bash +composer test # base (unit) + working (end-to-end) +composer test-base # unit-level class correctness only +composer test-working # real end-to-end scenarios only +composer test-detail # testdox (human-readable) output +``` + +**Container tier** — heavy, environment-specific checks (leak / timing / Swoole / +load), run inside Docker across PHP versions: + +```bash +tests/run-container.sh # default versions: 8.4 8.5 +tests/run-container.sh 8.4 # a single version +``` + +## Code style + +PSR-12, enforced by `phpcs`: + +```bash +composer cs-check # report violations (src/) +composer cs-fix # auto-fix what it can +``` + +All `src/` files must declare `strict_types=1`. CI runs `cs-check` + the default +suite on a PHP 8.4 / 8.5 matrix, and the container suite in Docker. + +## Pull requests + +1. **Branch** off `main`. +2. **Add tests** for any behavior change — a bug fix gets a regression test; a + feature gets coverage in the appropriate tier (`tests/Base` for isolated class + correctness, `tests/Working` for end-to-end, `tests/Container` for + environment-specific). +3. **Keep docs in sync.** The `docs/*` files are the source of truth for behavior; + if you change a signature or a default, update the matching doc (especially + [docs/14 — API Reference](docs/14-api-reference.md)) in the same PR. +4. **Run `composer cs-check` and `composer test`** locally; both must be green. +5. Keep changes focused; explain the *why* in the PR description. + +## Project layout + +- `src/` — the library. `Thread` is a thin facade; the real work lives in the + `Engine` / `Launcher` / `Runner` / `PayloadTransport` primitives. Read + [docs/11 — Architecture](docs/11-architecture.md) before making structural + changes — note that the **parent-side `Engine`** and the **child-side `Runner`** + are deliberately independent. +- `wRunner` — the child bootstrap script (packaged as `bin`). +- `tests/` — `Base` / `Working` / `Container` tiers, plus `Fixtures` and `docker`. +- `docs/` — the documentation set. + +## Reporting bugs & security issues + +- **Bugs:** open a GitHub issue with a minimal reproduction, your PHP version, and + OS. +- **Security vulnerabilities:** do **not** open a public issue — follow + [SECURITY.md](SECURITY.md). + +## License + +By contributing you agree that your contributions are licensed under the project's +[MIT license](LICENSE). diff --git a/README.md b/README.md index 21cb515..e231377 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,44 @@ # Winter Thread: A Modern Process Control Library for PHP +[![Tests](https://img.shields.io/github/actions/workflow/status/flytachi/winter-thread/ci.yml?branch=main&label=tests)](https://github.com/flytachi/winter-thread/actions/workflows/ci.yml) [![Latest Version on Packagist](https://img.shields.io/packagist/v/flytachi/winter-thread.svg)](https://packagist.org/packages/flytachi/winter-thread) [![PHP Version Require](https://img.shields.io/packagist/php-v/flytachi/winter-thread.svg?style=flat-square)](https://packagist.org/packages/flytachi/winter-thread) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) -**Winter Thread** provides a clean, object-oriented API for running and controlling background processes in PHP, simulating a traditional threading model for parallel and long-running tasks. +**Winter Thread** is a *process engine* for PHP: a clean, object-oriented, +Java-like API for running and controlling background tasks as isolated OS +processes — for parallel and long-running work. -It abstracts away the complexities of `proc_open` and POSIX signals into a powerful and easy-to-use interface. +**No heavy extensions.** Unlike `pthreads`, `ext-parallel`, or Swoole, it needs +no ZTS build and no exotic runtime — just `proc_open` and the standard POSIX +extensions (`ext-pcntl`, `ext-posix`) that ship with nearly every PHP install. +Each task runs in a fresh, isolated PHP process, so there is no shared state to +corrupt and no inherited connections to break. + +It is deliberately a low-level **engine** — a small, dependable core to build +pools, queues and schedulers on — that abstracts away `proc_open` and POSIX +signals behind a friendly API. ## Key Features +- **No heavy extensions**: No swoole / parallel / pthreads, no ZTS build — just `proc_open` + standard POSIX. Runs on a normal PHP install. +- **Clean process isolation**: Each task runs in a brand-new PHP process — no inherited DB connections, sockets, or global state to corrupt. - **Fluent, Object-Oriented API**: Manage background processes as objects. - **Full Process Control**: `start()`, `join()`, `pause()`, `resume()`, `terminate()`, and `kill()`. - **Advanced Process Naming**: Identify your processes easily with namespaces, names, and tags. - **Safe by Default**: Output goes to `/dev/null` by default — no Broken pipe risk for fire-and-forget jobs. -- **Swoole / Event-Loop Compatible**: Three payload delivery modes (`PIPE`, `TEMP_FILE`, `SHM`) to avoid fd corruption under `SWOOLE_HOOK_ALL`. -- **Extensible**: Easily override the runner script for deep framework integration. +- **Swoole / Event-Loop Compatible**: Pluggable payload transports (pipe, temp-file, shared-memory); the default engine auto-detects an active Swoole runtime and avoids fd corruption under `SWOOLE_HOOK_ALL`. +- **Zombie-free fire-and-forget**: Optional detached mode (`fork` + `setsid`) reparents workers to init, so long-lived parents (FPM, daemons) never accumulate zombies. +- **Pluggable Engine**: Swap the payload transport or the launcher through a single `Engine` — build custom backends (Docker, SSH, …) without touching `Thread`. - **Java-like API**: Familiar method names like `isAlive()` and `join()` for an easy learning curve. ## Requirements -- PHP >= 8.1 +- PHP >= 8.4 - `ext-pcntl` - `ext-posix` - `opis/closure` ^4.5 (required; enables safe serialization of anonymous classes and closures) +- `ext-shmop` (optional; only for the shared-memory transport) ## Installation @@ -76,39 +91,58 @@ $exitCode = $thread->join(); echo "Task finished with exit code: $exitCode\n"; ``` -## Swoole / Event-Loop Compatibility - -Under **Swoole** with `SWOOLE_HOOK_ALL`, stdin pipes created by `proc_open` are intercepted -and their file descriptors leak into Swoole's internal table, causing `Bad file descriptor` -errors on subsequent requests. +## Configuration — the Engine -Configure the payload mode **once at bootstrap** to avoid pipe fds entirely: +All configuration goes through a single `Engine`, bound **once at bootstrap** with +`Thread::bindEngine()`. When you bind nothing, the **`AdaptiveEngine`** is used and +self-configures for the current environment (CLI / FPM / Swoole). ```php -use Flytachi\Winter\Thread\Thread; - -// bootstrap.php — loaded by every Swoole worker process -if (extension_loaded('swoole') && \Swoole\Coroutine::getCid() !== -1) { - // PAYLOAD_TEMP_FILE: payload written to a temp file, unlinked immediately — no ext needed - // PAYLOAD_SHM: payload in shared memory (RAM only) — requires ext-shmop - Thread::bindPayloadMode( - extension_loaded('shmop') - ? Thread::PAYLOAD_SHM - : Thread::PAYLOAD_TEMP_FILE - ); -} +use Flytachi\Winter\Thread\Engine\ManualEngine; +use Flytachi\Winter\Thread\Payload\TempFileTransport; + +// Zero-config: AdaptiveEngine is the default — nothing to do. +$thread = new Thread(new MyTask()); +$thread->start(); + +// Explicit configuration when you need it: +Thread::bindEngine( + (new ManualEngine()) + ->withTransport(new TempFileTransport()) + ->withBinaryPath('/usr/bin/php') + ->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner') + ->withSecurity('your-signing-secret') // signs serialized closures + ->withLauncher(new MyCustomLauncher()) // optional: custom backend +); ``` -After that, `Thread::start()` works identically — no other code changes needed. +### Swoole / Event-Loop Compatibility + +Under **Swoole** with `SWOOLE_HOOK_ALL`, stdin pipes created by `proc_open` are intercepted +and their file descriptors leak into Swoole's internal table, causing `Bad file descriptor` +errors. The `AdaptiveEngine` **detects an active Swoole runtime automatically** and switches +to a file/shared-memory transport, so no configuration is needed. Under Swoole, also prefer +file output over `outputTarget: null` (the output pipes are subject to the same hooks). -| Mode | Delivery | Parent pipe fd | Requires | +| Transport | Delivery | Parent pipe fd | Requires | |---|---|---|---| -| `PAYLOAD_PIPE` | stdin pipe (default) | yes | — | -| `PAYLOAD_TEMP_FILE` | temp file as stdin | **none** | — | -| `PAYLOAD_SHM` | shared memory | **none** | `ext-shmop` | +| `PipeTransport` | stdin pipe (default in CLI) | yes | — | +| `TempFileTransport` | temp file as stdin | **none** | — | +| `ShmTransport` | shared memory | **none** | `ext-shmop` | + +## Detached (zombie-free) fire-and-forget + +For a long-lived parent (FPM worker, daemon) that dispatches background tasks and never +joins them, pass `detached: true`. The launcher exits immediately and the real worker is +reparented to init (`pid 1`), so no zombie ever accumulates under the parent: -See [6. Payload Modes](docs/06-payload-modes.md) for full details, security notes, and the -isolation guarantee for concurrent calls. +```php +$thread = new Thread(new SendEmailBatch($ids)); +$thread->start(detached: true); // returns at once; worker owned by init +``` + +Signal control still works via the worker's self-reported PID (write `getmypid()` from +inside the task to your own store), since the engine's control model is PID-based. --- @@ -136,28 +170,58 @@ $thread->isAlive(); // bool — check if still running ## Running Tests +Tests come in two tiers (mirroring the `winter-kernel` layout): + +**Default** — runs on any machine; unsupported extensions self-skip: + ```bash composer install -composer test -# or with human-readable output: -composer test-detail +composer test # base (class correctness) + working (scenarios) +composer test-base # only unit-level class correctness +composer test-working # only end-to-end scenarios +composer test-detail # human-readable (testdox) output ``` -## Documentation +**Containered** — heavy, environment-specific checks (leak / timing / nested / battle-run, +with Cli / FPM / Swoole), run inside Docker across a list of PHP versions: -Full documentation is in the [/docs](docs) directory: +```bash +tests/run-container.sh # default versions: 8.4 8.5 +tests/run-container.sh 8.4 # a single version +tests/run-container.sh 8.4 8.5 8.6 # a custom list -- [1. Introduction](docs/01-introduction.md) -- [2. Installation and Requirements](docs/02-installation-and-requirements.md) -- [3. Basic Usage](docs/03-basic-usage.md) -- [4. Debugging and Output Handling](docs/04-debugging-and-output.md) -- [5. API Reference](docs/05-api-reference.md) -- [6. Payload Modes (Swoole / Event-Loop Compatibility)](docs/06-payload-modes.md) +# Or, inside an environment that already has swoole/shmop: +composer test-container # phpunit --testsuite container +``` + +CI (`.github/workflows/ci.yml`) runs the default suite via `setup-php` and the container +suite via the bundled `tests/docker/Dockerfile`, on a PHP 8.4 / 8.5 matrix. + +## Documentation + +Full documentation lives in [`/docs`](docs/README.md): + +1. [Introduction](docs/01-introduction.md) — philosophy, the no-heavy-ext story, when to use it +2. [Installation & Requirements](docs/02-installation-and-requirements.md) +3. [Quickstart](docs/03-quickstart.md) — a complete parallel example in 5 minutes +4. [Basic Usage](docs/04-basic-usage.md) +5. [Output & Debugging](docs/05-output-and-debugging.md) +6. [Process Control & Lifecycle](docs/06-process-control.md) — signals, graceful shutdown +7. [The Engine](docs/07-the-engine.md) +8. [Payload Transports](docs/08-payload-transports.md) +9. [Detached Mode](docs/09-detached-mode.md) +10. [Security](docs/10-security.md) +11. [Architecture & Internals](docs/11-architecture.md) +12. [Patterns](docs/12-patterns.md) — pools, returning results, retries +13. [Troubleshooting](docs/13-troubleshooting.md) +14. [API Reference](docs/14-api-reference.md) +15. [Testing](docs/15-testing.md) ## Contributing Contributions are welcome! Please submit a pull request or open an issue for bugs, questions, or feature requests. + ## License -This library is open-source software licensed under the [MIT license](LICENSE). +This library is open-source software licensed under the [MIT license](LICENSE). \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bbb4c51 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| 2.x | ✅ | +| < 2.0 | ❌ | + +Security fixes land on the latest `2.x` line. + +## Reporting a vulnerability + +**Please do not open a public issue for security reports.** + +Report privately through GitHub's **[Security Advisories](https://github.com/flytachi/winter-thread/security/advisories/new)** +("Report a vulnerability" on the repository's *Security* tab). Include: + +- affected version(s) and PHP version / OS; +- a description and, ideally, a minimal reproduction; +- the impact you believe it has. + +You can expect an acknowledgement within a few days. Once a fix is ready, a +patched release is published and the advisory is disclosed with credit (unless you +prefer to remain anonymous). + +## Security model (what the library guarantees, and what it doesn't) + +Winter Thread serializes a task in the parent and deserializes it in a worker +process, so its threat model centers on **payload integrity**. See +[docs/10 — Security](docs/10-security.md); in short: + +- **Deserialization is always via `opis/closure`**, never native `unserialize()`. +- **Signing is opt-in.** Set a secret (`WINTER_THREAD_SECRET` env, or + `withSecurity()`) and every payload is HMAC-signed by the parent and verified by + the child; forged/tampered payloads are rejected before any object is built. With + **no** secret, the payload is unsigned and the trust boundary is the private, + owner-only delivery channel (stdin pipe, `0600` temp file, or `0600` shared + memory). +- **The signing secret travels only in the environment** (`WINTER_THREAD_SECRET`, + owner-readable `/proc//environ`), **never on argv**. +- **The serialized task never appears on the command line.** Only safe flags do + (`--namespace`, `--name`, `--tag`, `--debug`, `--detach`, `--arg-*`, `--shmkey`), + each `escapeshellarg()`-escaped. + +### Your responsibilities + +- **Set a secret in production** (`WINTER_THREAD_SECRET` or `withSecurity()`) — long + and random — so payloads are cryptographically verified. +- **Never put secrets in `start()` arguments or in the namespace/name/tag** — those + are visible via `ps` / `/proc//cmdline` to same-user processes. Put sensitive + data in the task's **constructor** (it rides in the payload, not argv). +- **Scope `proc_open`** (via `disable_functions` elsewhere) to where you intend to + spawn processes. +- The `0600` shared-memory segment blocks other users, but same-uid processes can + attach via the key on argv — treat same-uid as a trust boundary. + +Full details and rationale: **[docs/10-security](docs/10-security.md)** and the +[documentation index](docs/README.md). diff --git a/composer.json b/composer.json index 2ba7c49..0db4d99 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,14 @@ { "name": "flytachi/winter-thread", - "description": "Object-oriented background process control for PHP — a Java-like threading model over OS processes.", + "description": "A lightweight process engine for PHP — Java-like control of background tasks as isolated OS processes, without heavy extensions (no swoole/pthreads/parallel).", "type": "library", "license": "MIT", "scripts": { "test": "phpunit", "test-detail": "phpunit --testdox", + "test-base": "phpunit --testsuite base", + "test-working": "phpunit --testsuite working", + "test-container": "phpunit --testsuite container", "cs-check": "phpcs .", "cs-fix": "phpcbf ." }, @@ -19,19 +22,19 @@ "Flytachi\\Winter\\Thread\\Tests\\": "tests/" } }, - "bin": ["wExecutor"], + "bin": ["wRunner"], "require": { - "php": ">=8.1", + "php": ">=8.4", "ext-pcntl": "*", "ext-posix": "*", "opis/closure": "^4.5" }, "require-dev": { "phpunit/phpunit": "^13.1", - "squizlabs/php_codesniffer": "@stable" + "squizlabs/php_codesniffer": "^4.0" }, "suggest": { - "ext-shmop": "Required for Thread::PAYLOAD_SHM mode (Swoole-compatible shared memory payload delivery)" + "ext-shmop": "Required for ShmTransport (Swoole-compatible shared-memory payload delivery)" }, "homepage": "https://winterframe.net", "support": { diff --git a/docs/01-introduction.md b/docs/01-introduction.md index 783e59d..2785d2b 100644 --- a/docs/01-introduction.md +++ b/docs/01-introduction.md @@ -1,24 +1,142 @@ # 1. Introduction -Welcome to the official documentation for Winter Thread. +**Winter Thread** is a *process engine* for PHP: an object-oriented, Java-like API +for running and controlling background tasks as isolated OS processes. -Winter Thread is a modern, object-oriented library designed to simplify background process management in PHP. It provides a robust, high-level API that abstracts away the complexities of low-level process control, making it easy to run, monitor, and interact with parallel tasks. +It is deliberately a **low-level engine** — a small, dependable core you build +higher-level concurrency on (pools, queues, schedulers, workers), rather than a +batteries-included framework. It gives you clean primitives and stays out of your +way. -## Philosophy +## The core idea -The core philosophy behind Winter Thread is to provide a developer-friendly, Java-like threading model in a language that traditionally lacks built-in multi-threading capabilities. We achieve this by leveraging the power of separate OS-level processes, which offers several key advantages: +A task is any object implementing [`Runnable`](../src/Runnable.php). You wrap it +in a `Thread` and start it. Under the hood Winter Thread serializes the task, +spawns a **fresh PHP CLI process**, and runs the task there — completely isolated +from the parent: -- **True Parallelism**: Each task runs in its own process, allowing it to be scheduled on a different CPU core by the operating system, achieving true parallel execution. -- **Isolation**: Processes are completely isolated from one another. A fatal error or memory leak in one child process will not affect the main application or other child processes. -- **Simplicity**: By providing a clean `Thread` and `Runnable` API, developers can focus on their task logic instead of wrestling with `proc_open`, pipes, process signals, and serialization. +```php +class SendReport implements Runnable { + public function __construct(private int $userId) {} + public function run(array $args): void { + // Runs in its own clean PHP process. + } +} -## Core Concepts +$thread = new Thread(new SendReport(42)); +$pid = $thread->start(); // returns immediately, gives you the worker PID +$thread->join(); // optionally wait for the exit code +``` -- **Thread**: The main object that manages a child process. You create a `Thread` instance for each background task you want to run. It is responsible for starting, stopping, and monitoring the process. -- **Runnable**: An interface that represents a unit of work. Any class that implements the `Runnable` interface can be executed by a `Thread`. The core logic of your task resides in the `run()` method. -- **Runner Script**: An internal, executable PHP script (`runner`) that is responsible for bootstrapping the environment in the new process, deserializing the `Runnable` task, and executing it. +### What actually happens on `start()` -This library is designed for tasks that can be run independently and do not require complex inter-process communication (IPC), such as: -- Processing heavy computational jobs (image/video encoding, report generation). -- Running I/O-bound tasks (sending email batches, calling external APIs). -- Offloading any long-running or blocking operation from the main application thread to keep it responsive. +1. The `Runnable` is serialized with [`opis/closure`](10-security.md) (optionally + HMAC-signed). +2. A [payload transport](08-payload-transports.md) *stages* those bytes for + delivery (stdin pipe by default). +3. The engine builds a shell-escaped command like + `php /wRunner --namespace=… --name=…` and runs it through `proc_open`. +4. `proc_open` returns immediately with a live process; you get its PID. +5. The child bootstrap script `wRunner` reads the payload back, verifies and + deserializes it into your `Runnable`, and calls `run()`. + +Everything after step 4 happens **concurrently** with your main script. That is +the whole point. + +## No heavy extensions + +This is the defining trait of the engine. Real parallelism in PHP usually means +one of the heavyweight options: + +| Approach | Needs | +|---|---| +| `pthreads` | a ZTS PHP build + PECL extension (abandoned on modern PHP) | +| `ext-parallel` | a ZTS build + PECL extension | +| Swoole / OpenSwoole | a large C extension + an event-loop programming model | + +**Winter Thread needs none of them.** It runs on a *standard* PHP build using: + +- **`proc_open`** — a core function, always available (unless explicitly disabled + via `disable_functions`); +- **`ext-pcntl` + `ext-posix`** — lightweight, standard POSIX extensions that ship + with virtually every Linux/macOS PHP CLI, used for signals and the optional + detached mode; +- **`opis/closure`** — a pure-PHP Composer package for safe serialization. + +`ext-shmop` is *optional* and only needed for the [shared-memory +transport](08-payload-transports.md). There is no ZTS requirement, no event loop, +no exotic runtime. **If your PHP can call `proc_open`, the engine works** — and it +[coexists with Swoole](08-payload-transports.md#swoole--event-loop-compatibility) +when you do run one. + +## Why processes instead of threads + +PHP has no safe shared-memory threads. Winter Thread embraces that and spawns +**processes**, which fits how modern PHP applications are actually built: + +- **Clean isolation.** Each task starts in a brand-new PHP process — no inherited + database connections, Redis sockets, DI container, opcode state, or globals. + There is nothing to corrupt and nothing to leak between tasks. +- **No `fork()` foot-guns.** Approaches based on `pcntl_fork()` of your *main* + process duplicate it — including open PDO/Redis file descriptors, which break in + subtle ways when a child closes them. A fresh `proc_open` process simply doesn't + have them. (Detached mode *does* use one `fork`, but inside the already-clean + worker — never your app; see [9. Detached Mode](09-detached-mode.md).) +- **Runs where you run.** Because it is just `proc_open`, tasks can be launched + from a CLI worker, a daemon, or even inside a PHP-FPM web request (where + `proc_open` is permitted). + +## What you get + +- A fluent, **Java-like API**: `start()`, `join()`, `isAlive()`, `pause()`, + `resume()`, `interrupt()`, `terminate()`, `kill()`, plus `reap()`, `detach()` + and `getExitCode()`. +- **Pluggable payload transports** (pipe / temp-file / shared-memory) with + automatic selection under Swoole. +- **Zombie-free fire-and-forget** via an optional [detached mode](09-detached-mode.md). +- **Signed serialization** to defend against payload tampering / object injection. +- A **pluggable [`Engine`](07-the-engine.md)** so you can swap the payload transport + or the launcher — and build custom backends (Docker, SSH, …) — without touching + `Thread`. +- A **non-blocking control model** (`reap()`/`detach()` never stall on a live + worker), so a single loop can drive hundreds of workers — the foundation for a + pool. + +## When to use it + +**Great for:** + +- heavy background jobs that must not block the request/worker (reports, exports, + parsing, media processing, batch e-mail); +- long-running or blocking work you want isolated from the main process; +- a foundation for your own pool / scheduler / worker primitives. + +**Not ideal for:** + +- **Huge numbers of *tiny* tasks.** Each task is a full PHP process; spawning one + costs a few milliseconds of interpreter start-up plus the serialize/deserialize + round-trip. For thousands of microsecond-sized operations that overhead + dominates — use it for work meaningfully larger than the spawn cost, or amortize + the cost with a long-lived worker that pulls many jobs. +- **Shared mutable state between tasks.** Isolation is a feature, not a limitation + to work around: workers don't share memory. Coordinate through a database, a + queue, or files — not in-process variables. +- **Windows.** The engine targets POSIX (signals, `setsid`, `/proc`); it is + developed and tested on Linux and macOS. + +## Where to next + +- [2. Installation & Requirements](02-installation-and-requirements.md) +- [3. Quickstart](03-quickstart.md) — a complete parallel example in 5 minutes +- [4. Basic Usage](04-basic-usage.md) +- [5. Output & Debugging](05-output-and-debugging.md) +- [6. Process Control & Lifecycle](06-process-control.md) +- [7. The Engine](07-the-engine.md) +- [8. Payload Transports](08-payload-transports.md) +- [9. Detached Mode](09-detached-mode.md) +- [10. Security](10-security.md) +- [11. Architecture & Internals](11-architecture.md) +- [12. Patterns](12-patterns.md) — pools, returning results, retries +- [13. Troubleshooting](13-troubleshooting.md) +- [14. API Reference](14-api-reference.md) +- [15. Testing](15-testing.md) diff --git a/docs/02-installation-and-requirements.md b/docs/02-installation-and-requirements.md index 1c67fb5..e42bcb0 100644 --- a/docs/02-installation-and-requirements.md +++ b/docs/02-installation-and-requirements.md @@ -1,79 +1,112 @@ -# 2. Installation and Requirements +# 2. Installation & Requirements -To use Winter Thread, your PHP environment must meet a few key requirements. +## Install -## System Requirements - -- **PHP Version**: PHP 8.1 or higher. -- **Operating System**: A POSIX-compliant operating system (e.g., Linux, macOS, BSD). This library relies on POSIX signals and process control functions and is **not compatible with Windows**. -- **PHP Extensions**: - - `ext-pcntl`: The Process Control extension is essential for process forking and management. - - `ext-posix`: The POSIX extension is required for sending signals (`posix_kill`) and identifying processes. +```bash +composer require flytachi/winter-thread +``` -You can check for installed extensions by running `php -m` in your terminal. +Installing the package also exposes the child bootstrap script as +`vendor/bin/wRunner`. You never call it by hand — the engine invokes it for you — +but it must remain executable and reachable on disk (see +[Runner path](#the-runner-path-wrunner) below). -## Optional Dependencies +## Requirements -### `ext-shmop` for Shared Memory Payload Mode +| Requirement | Why | Mandatory? | +|---|---|---| +| **PHP >= 8.4** | modern language features (`readonly` classes, first-class callable syntax, named args) | ✅ | +| **`proc_open`** | spawns the worker processes (core function) | ✅ | +| **`ext-pcntl`** | `pcntl_fork()` for [detached mode](09-detached-mode.md) | ✅ (Composer) | +| **`ext-posix`** | `posix_kill()` (signals) and `posix_setsid()` (detached mode) | ✅ (Composer) | +| **`opis/closure` ^4.5** | safe serialization of closures / anonymous classes and signed payloads | ✅ (Composer) | +| **`ext-shmop`** | only for the [shared-memory transport](08-payload-transports.md) | ⚪ optional | -To use `Thread::PAYLOAD_SHM` (shared memory payload delivery, recommended for high-throughput -Swoole environments), the `ext-shmop` PHP extension must be loaded. +`ext-pcntl` and `ext-posix` are standard, lightweight POSIX extensions bundled +with almost every Linux/macOS PHP CLI — nothing exotic. There is **no** ZTS build +requirement and **no** heavy extension (swoole/parallel/pthreads) involved. -```bash -# Check if already available -php -m | grep shmop -``` +> **What each dependency actually gates.** The bare spawn/wait path +> (`start()` → `join()`/`reap()`) only needs `proc_open`. `ext-posix` powers +> signal control (`pause`, `resume`, `interrupt`, `terminate`, `kill`, and the +> zombie-aware [`Signal`](../src/Signal.php) helper); `ext-pcntl` powers detached +> mode's `fork`. The package requires both so the full feature set always works — +> but if a platform lacks one, only the corresponding feature is affected. -Most production PHP builds include `ext-shmop`. If it is not available, use -`Thread::PAYLOAD_TEMP_FILE` instead — it requires no extra extensions. +`ext-shmop` is checked at runtime: [`ShmTransport`](08-payload-transports.md) +throws a clear `ThreadException` ("ShmTransport requires ext-shmop.") on both the +staging and receiving side if the extension is missing — never a fatal error. -See [6. Payload Modes](06-payload-modes.md) for full details. +## Environment notes ---- +### PHP-FPM / web SAPI -## Closure and Anonymous-Class Serialization - -`opis/closure` is a **required dependency** — it is installed automatically with the package -(`composer require flytachi/winter-thread`), so there is no separate step. It is what lets -anonymous classes and `Closure` objects be serialized and executed in a background thread; -PHP's native `serialize()` cannot handle them and throws on a closure or `class@anonymous`. - -When serializing closures or anonymous classes, you must also define a secret key. -This key is used to sign the serialized closure, preventing remote code execution vulnerabilities. -Define it once at the beginning of your application's lifecycle using `Thread::bindSerSecurity()`: +`proc_open` must be permitted (not listed in `disable_functions`). Under FPM, +`PHP_BINARY` points at the **FPM** binary, not a CLI one — running your worker +through that would be wrong. The default [`AdaptiveEngine`](07-the-engine.md) +detects a non-CLI SAPI and resolves a real PHP CLI binary from `PHP_BINDIR` +instead. If detection fails in an unusual setup, set the path explicitly with a +[`ManualEngine`](07-the-engine.md): ```php -use Flytachi\Winter\Thread\Thread; - -Thread::bindSerSecurity('your-secret-key'); +Thread::bindEngine( + (new ManualEngine()) + ->withBinaryPath('/usr/bin/php') + ->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner') + ->withTransport(new \Flytachi\Winter\Thread\Payload\PipeTransport()) +); ``` -## Configuration +> A `ManualEngine` configures **nothing** for you: `transport`, `binaryPath` and +> `runnerPath` must all be set or the engine throws when they are accessed. Use it +> only when you deliberately want full control; otherwise the `AdaptiveEngine` +> handles FPM correctly on its own. -The library provides several static methods for configuration. -Call these once during your application's bootstrap phase. +### The runner path (`wRunner`) -### Custom Runner Script +The child is bootstrapped by the `wRunner` script shipped in the package root. +The `AdaptiveEngine` locates it automatically relative to the installed package +(`vendor/flytachi/winter-thread/wRunner`). Two situations need attention: -The internal runner script is located in the library's root directory and is automatically found. -If you need to customize the path to the runner script (for example, for deep framework integration), -you can use `Thread::bindRunner()`: +- **Phar / relocated deployments.** If your code is packed into a `.phar` or the + vendor directory is not on a normal filesystem path, the script may not be + directly executable. Point a `ManualEngine` at a real on-disk copy with + `->withRunnerPath(...)`. +- **`open_basedir`.** The binary and runner paths must be inside any configured + `open_basedir`. -```php -use Flytachi\Winter\Thread\Thread; +### Windows -Thread::bindRunner('/path/to/your/custom/runner'); -``` +The engine targets POSIX (signals, `setsid`, `/proc`). It is developed and tested +on Linux and macOS. Windows is not supported. + +### Containers -### Custom PHP Binary Path +If you run [detached](09-detached-mode.md) tasks with your app as **PID 1** in a +container, add a reaping init (`docker run --init`, or `init: true` in Compose) so +orphaned workers are collected. Without it, detached workers reparent to your app +(PID 1) which does not reap them, and they accumulate as zombies. Attached +tasks that you `join()`/`reap()` do **not** need this. Details in +[9. Detached Mode](09-detached-mode.md). -When running under PHP-FPM, CGI, or other web SAPIs, `PHP_BINARY` returns the path -to the web handler (e.g., `/usr/sbin/php-fpm83`), not the CLI binary needed -for spawning background processes. If you need to explicitly specify the PHP CLI binary path, -use `Thread::bindBinaryPath()`: +## Verify your install ```php +start() . PHP_EOL; +echo 'exit: ' . $thread->join() . PHP_EOL; // 0 +``` + +Expected output is a numeric PID followed by `exit: 0`. Anonymous classes work +because `opis/closure` is a hard dependency — you are not restricted to named +task classes (though named classes are recommended for readable process titles +and simpler debugging). diff --git a/docs/03-basic-usage.md b/docs/03-basic-usage.md deleted file mode 100644 index d7617a7..0000000 --- a/docs/03-basic-usage.md +++ /dev/null @@ -1,189 +0,0 @@ -# 3. Basic Usage - -The core workflow of Winter Thread: define a task, wrap it in a `Thread`, start it. - ---- - -## Step 1: Create a Runnable Task - -Implement the `Runnable` interface. All task logic goes inside `run()`. - -**Critical constraint:** the `Runnable` object is serialized and passed to the child process -via stdin. Properties must not contain non-serializable values such as database connections, -file handles, or sockets. Create those resources *inside* `run()`. - -```php -query("SELECT * FROM orders WHERE report_id = {$this->reportId}") - ->fetchAll(); - - file_put_contents( - "/tmp/report-{$this->reportId}.json", - json_encode($rows) - ); - } -} -``` - ---- - -## Step 2: Start the Thread - -Create a `Thread` with your task and call `start()`. The method returns immediately with -the child process PID — your main script is never blocked. - -By default, output from the child goes to `/dev/null`. This is the safest option for -background jobs: no pipe is opened, so no **Broken pipe** risk regardless of how long the -job runs or whether the parent stays alive. - -```php -start(); // non-blocking; output goes to /dev/null by default -echo "Report generation started in background (PID: $pid)\n"; - -// Main script continues immediately -doOtherWork(); -``` - ---- - -## Step 3: Wait for Completion (Optional) - -If you need the result before continuing, call `join()`. It blocks until the child exits -and returns the exit code (`0` = success, non-zero = failure). - -```php -$exitCode = $thread->join(); - -if ($exitCode === 0) { - echo "Report ready.\n"; -} else { - echo "Report failed (exit code: $exitCode).\n"; -} -``` - -To avoid waiting indefinitely, pass a timeout in seconds: - -```php -$exitCode = $thread->join(timeout: 30); - -if ($exitCode === null) { - echo "Timed out — task is still running.\n"; - $thread->kill(); // force-stop if needed -} -``` - ---- - -## Step 4: Pass Custom Arguments (Optional) - -Pass an associative array to `start()`. Arguments become available in `run()` via `$args`. - -Rules: -- `'key' => 'value'` → `$args['key'] === 'value'` -- `'flag' => true` → `$args['flag'] === true` (valueless flag) -- `'skip' => false` or `'skip' => null` → not included in `$args` - -```php -class ExportTask implements Runnable -{ - public function run(array $args): void - { - $format = $args['format'] ?? 'csv'; - $dryRun = isset($args['dry-run']); - $userId = $args['user-id'] ?? null; - - echo "Exporting as $format" . ($dryRun ? ' (dry run)' : '') . "\n"; - } -} - -$thread = new Thread(new ExportTask()); -$thread->start([ - 'format' => 'json', - 'user-id' => '42', - 'dry-run' => true, -]); -$thread->join(); -``` - ---- - -## Step 5: Log Output to a File (Optional) - -For tasks that produce output you want to keep, pass a file path as `$outputTarget`. -Output is appended — safe to reuse the same file across multiple jobs. - -```php -$thread->start( - debugMode: true, // enable PHP error reporting in child - outputTarget: '/var/log/app/reports.log' -); -``` - -See [4. Debugging and Output Handling](04-debugging-and-output.md) for all output modes. - ---- - -## Full Example - -```php -file}.done", "quality=$quality"); - } -} - -$thread = new Thread(new VideoProcessor('movie.mp4'), 'Media', 'VideoProcessor', 'job-7'); -$pid = $thread->start(['quality' => 'hd'], outputTarget: '/var/log/app/video.log'); - -echo "Encoding started (PID: $pid). Doing other work...\n"; -sleep(2); - -$exitCode = $thread->join(timeout: 60); - -if ($exitCode === null) { - echo "Encoding is taking too long, killing...\n"; - $thread->kill(); -} elseif ($exitCode === 0) { - echo "Encoding complete.\n"; -} else { - echo "Encoding failed (code: $exitCode). Check /var/log/app/video.log\n"; -} -``` diff --git a/docs/03-quickstart.md b/docs/03-quickstart.md new file mode 100644 index 0000000..a4544e6 --- /dev/null +++ b/docs/03-quickstart.md @@ -0,0 +1,174 @@ +# 3. Quickstart + +A complete, runnable example in five minutes. We'll run **three jobs in parallel**, +each producing a result file, then collect their results in the parent. By the end +you'll have used the whole core loop: *define → start → wait → collect*. + +> Prerequisite: the package is installed (see +> [2. Installation](02-installation-and-requirements.md)). Everything below is one +> self-contained CLI script. + +## Step 1 — Define the task + +A task implements [`Runnable`](14-api-reference.md#runnable-interface); its logic +lives in `run()`, which executes in a **separate, clean PHP process**. Because +workers don't share memory with the parent, we return the result by **writing a +file** (a database row or queue message works the same way): + +```php +use Flytachi\Winter\Thread\Runnable; + +final class ComputeStats implements Runnable +{ + /** @param int[] $numbers */ + public function __construct( + private array $numbers, + private string $outDir, + ) {} + + public function run(array $args): void + { + $label = $args['label'] ?? 'batch'; // a per-run argument (a string) + $sum = array_sum($this->numbers); + $avg = $sum / max(1, count($this->numbers)); + + // "Return" the result by writing it where the parent can read it. + file_put_contents( + "{$this->outDir}/{$label}.json", + json_encode(['label' => $label, 'sum' => $sum, 'avg' => $avg]), + ); + } +} +``` + +## Step 2 — Start jobs in parallel + +Wrap each task in a [`Thread`](14-api-reference.md#thread-final-class) and +`start()` it. `start()` returns **immediately** with the worker's PID — all three +run at the same time: + +```php +use Flytachi\Winter\Thread\Thread; + +$dir = sys_get_temp_dir() . '/wt-quickstart'; +@mkdir($dir); + +$batches = [ + 'evens' => [2, 4, 6, 8], + 'odds' => [1, 3, 5, 7], + 'primes'=> [2, 3, 5, 7, 11], +]; + +/** @var Thread[] $threads */ +$threads = []; +foreach ($batches as $label => $numbers) { + $t = new Thread(new ComputeStats($numbers, $dir), 'Stats', 'ComputeStats', $label); + $t->start(['label' => $label]); // fire — non-blocking + $threads[$label] = $t; + echo "started {$label} (pid {$t->getPid()})\n"; +} +``` + +## Step 3 — Wait and collect + +`join()` blocks until a worker finishes and returns its **exit code** (`0` = +success). Then read the result each worker wrote: + +```php +foreach ($threads as $label => $t) { + $exit = $t->join(); // wait for this worker + if ($exit !== 0) { + echo "{$label}: FAILED (exit {$exit})\n"; + continue; + } + $result = json_decode(file_get_contents("{$dir}/{$label}.json"), true); + echo "{$label}: sum={$result['sum']} avg={$result['avg']}\n"; +} +``` + +## Step 4 — Run it + +```bash +php quickstart.php +``` + +Expected output (PIDs vary; the three run concurrently): + +``` +started evens (pid 51234) +started odds (pid 51235) +started primes (pid 51236) +evens: sum=20 avg=5 +odds: sum=16 avg=4 +primes: sum=28 avg=5.6 +``` + +Three result files now exist under the temp dir — visible proof each task ran in +its own process. + +## The whole script + +```php +numbers); + $avg = $sum / max(1, count($this->numbers)); + file_put_contents( + "{$this->outDir}/{$label}.json", + json_encode(['label' => $label, 'sum' => $sum, 'avg' => $avg]), + ); + } +} + +$dir = sys_get_temp_dir() . '/wt-quickstart'; +@mkdir($dir); + +$batches = ['evens' => [2, 4, 6, 8], 'odds' => [1, 3, 5, 7], 'primes' => [2, 3, 5, 7, 11]]; + +$threads = []; +foreach ($batches as $label => $numbers) { + $t = new Thread(new ComputeStats($numbers, $dir), 'Stats', 'ComputeStats', $label); + $t->start(['label' => $label]); + $threads[$label] = $t; + echo "started {$label} (pid {$t->getPid()})\n"; +} + +foreach ($threads as $label => $t) { + $exit = $t->join(); + if ($exit !== 0) { echo "{$label}: FAILED (exit {$exit})\n"; continue; } + $r = json_decode(file_get_contents("{$dir}/{$label}.json"), true); + echo "{$label}: sum={$r['sum']} avg={$r['avg']}\n"; +} +``` + +## What you just used + +- **`Runnable`** — the task contract; logic in `run()`, executed in an isolated + process. ([4. Basic Usage](04-basic-usage.md)) +- **`Thread::start()`** — non-blocking launch, returns a PID. ([4](04-basic-usage.md)) +- **Arguments** — the `['label' => …]` map arrives in `run()`'s `$args` (as + strings/flags). ([4](04-basic-usage.md#arguments)) +- **`join()`** — wait for the exit code. ([6. Process Control](06-process-control.md)) +- **Returning results via a file** — the isolation-friendly pattern. ([12. Patterns](12-patterns.md#returning-a-result-from-a-task)) + +## Next steps + +- Fire-and-forget without waiting, and output handling → + [4. Basic Usage](04-basic-usage.md), [5. Output & Debugging](05-output-and-debugging.md). +- Run **many** jobs with bounded concurrency (a pool) → + [12. Patterns](12-patterns.md#a-bounded-worker-pool). +- Long-lived parent (FPM/daemon) that never waits → + [9. Detached Mode](09-detached-mode.md). +- Something not working? → [13. Troubleshooting](13-troubleshooting.md). diff --git a/docs/04-basic-usage.md b/docs/04-basic-usage.md new file mode 100644 index 0000000..e99a2f1 --- /dev/null +++ b/docs/04-basic-usage.md @@ -0,0 +1,237 @@ +# 4. Basic Usage + +The core workflow: **define a task**, **wrap it in a `Thread`**, **start it**, +and optionally **wait** for the result. + +## 1. Define a task with `Runnable` + +Any class implementing [`Runnable`](../src/Runnable.php) can run in a background +process. All logic goes in `run()`: + +```php +use Flytachi\Winter\Thread\Runnable; + +class GenerateReport implements Runnable +{ + public function __construct(private int $reportId) {} + + public function run(array $args): void + { + // Executes in a separate, clean PHP process. + $format = $args['format'] ?? 'pdf'; + // … heavy work … + } +} +``` + +### The `Runnable` contract + +```php +interface Runnable +{ + public function run(array $args): void; +} +``` + +- `run()` is the **only** entry point; the whole task lives here. +- Its return value is ignored — signal outcomes through the **exit code** + (`return`/normal completion → `0`; throwing → non-zero) or through side effects + (write to a DB, a file, a queue). +- `$args` is an associative array of per-run values (see [Arguments](#arguments)). + +### Serializability — the one hard rule + +The task object is **serialized in the parent and rebuilt in the child**, so +everything reachable from its properties must survive serialization: + +- ✅ Store scalars, arrays, and plain serializable objects (IDs, DTOs, config). +- ✅ Closures and anonymous classes are fine — `opis/closure` handles them. +- ❌ Do **not** store live resources: PDO/mysqli handles, open sockets, stream + resources, cURL handles, or objects holding them. They cannot cross a process + boundary. + +Open those resources **inside `run()`** instead — the entire point is that the +worker starts with a clean slate and its own fresh connections: + +```php +public function run(array $args): void +{ + $pdo = new PDO(...); // opened here, in the worker — not a property + // … +} +``` + +> **Keep the task lean.** The task's *entire* object graph is serialized and +> shipped over the transport on **every** `start()`. A constructor holding a large +> array or a fat DTO means a large payload and a slower spawn. Pass an **identifier** +> and load the heavy data inside `run()`, rather than embedding it in the task. + +## 2. Create a `Thread` + +```php +use Flytachi\Winter\Thread\Thread; + +$thread = new Thread( + new GenerateReport(42), + 'Reporting', // namespace (grouping, shown in the OS process title) + 'ReportBuilder', // name (optional; auto-derived from the class if null) + 'job-42' // tag (optional instance label) +); +``` + +Constructing a `Thread` does **not** start anything. The three metadata fields +are cosmetic but invaluable in production — they form the OS **process title** +(visible in `ps`/`htop`): + +``` +WinterThread Reporting -> ReportBuilder@job-42 +``` + +Details of each field: + +- **`namespace`** — a logical grouping (default `''`). +- **`name`** — if you pass `null`, it is auto-derived from the task's class: + the short class name (e.g. `GenerateReport`), or the literal `anonymous` for an + anonymous class. Pass a string to override. +- **`tag`** — an optional instance discriminator (default `null`); if omitted, the + process title shows `@runnable`. + +> The process title is only set when `cli_set_process_title()` is available on the +> platform; where it is not, the task still runs — you just don't get the pretty +> `ps` label. + +## 3. Start it + +`start()` serializes the task, launches the process, and returns its PID. It +**does not block**: + +```php +$pid = $thread->start(); +echo "started $pid\n"; +// main script keeps running immediately +``` + +### `start()` signature + +```php +public function start( + array $arguments = [], + bool $debugMode = false, + ?string $outputTarget = '/dev/null', + bool $detached = false, +): int +``` + +- `$arguments` — per-run values (below). +- `$debugMode` — enable child-side error reporting (see [5. Output & Debugging](05-output-and-debugging.md)). +- `$outputTarget` — where stdout/stderr go (see [5. Output & Debugging](05-output-and-debugging.md)). +- `$detached` — daemonize for zombie-free fire-and-forget (see [9. Detached Mode](09-detached-mode.md)). + +Returns the launched process **PID** (`int`). Throws +[`ThreadException`](14-api-reference.md#threadexception-class) if the process +fails to start (e.g. `proc_open` denied, bad binary/runner path, or the process +dies immediately). + +### One start per `Thread` + +A `Thread` guards against being started while it is already running: + +```php +$thread->start(); +$thread->start(); // ThreadException: "Thread is already running; join()/reap() it + // or create a new Thread before starting again." +``` + +To run the same task again, either `join()`/`reap()` the previous run first, or — +more commonly — create a **new** `Thread`. Reusing a `Thread` after it has +finished and been reaped is allowed; reusing it while alive is not. + +### Arguments + +Pass per-run values as the first argument to `start()`. They arrive in `run()`'s +`$args`: + +```php +$thread->start(['format' => 'csv', 'compress' => true]); + +public function run(array $args): void +{ + $format = $args['format']; // 'csv' (string) + $gz = $args['compress']; // true (bool) +} +``` + +Rules — worth knowing exactly, because they are strict: + +- Values must be **scalars or `null`**. Non-scalar, non-null values are silently + **dropped** (arrays/objects don't cross as arguments — put structured data in + the task's constructor instead, where it is serialized). +- `true` becomes a **valueless flag** and comes back as boolean `true`. +- `false` and `null` are **skipped entirely** — the key won't appear in `$args`. + So test presence with `??`/`isset`, not `=== false`. +- Every other scalar is stringified: an `int`/`float`/`bool`-in-a-string arrives + in `run()` as a **string** (`'42'`, not `42`). Cast as needed. +- Keys are stringified too. + +Internally they travel as escaped `--arg-[=]` CLI options and are +parsed back for you — you never touch the command line, and values cannot inject +into the shell. + +> **Arguments vs. constructor.** Use the **constructor** for the task's real +> payload (it is serialized, keeps types, and accepts any serializable value). Use +> **`$arguments`** for small per-run scalar switches (`format`, `verbose`). If you +> find yourself flattening structures into arguments, move them to the +> constructor. + +## 4. Wait for the result (optional) + +`join()` blocks until the task finishes and returns its exit code — `0` on +success, non-zero on failure: + +```php +$exit = $thread->join(); +if ($exit !== 0) { + // the task threw or failed +} +``` + +- `join(int $timeout = 0)` — waits up to `$timeout` **seconds** (`0` = forever). + Returns `null` on timeout, `-1` if the thread was never started. Internally it + polls process status every 50 ms. +- If you never call `join()`/`reap()`, see [6. Process Control](06-process-control.md) + for how the engine still avoids leaving zombie processes behind. + +## Fire-and-forget + +Don't want a result? Just start and move on. Output defaults to `/dev/null`, so +this is safe: + +```php +new Thread(new SendWelcomeEmail($userId))->start(); +``` + +For a **long-lived** parent (FPM worker, daemon) that never joins, use +[detached mode](09-detached-mode.md) so no zombie accumulates: + +```php +new Thread(new SendWelcomeEmail($userId))->start(detached: true); +``` + +## Exit codes & failures + +| Outcome | Exit code | Where to look | +|---|---|---| +| `run()` returns normally | `0` | — | +| `run()` throws an uncaught exception | non-zero (`1`) | message + stack trace on STDERR | +| Payload empty / not a `Runnable` | `1` | STDERR | +| Payload can't be deserialized (tampered, or signed with the wrong secret) | `1` | STDERR; **no task code runs** | + +The runner **always** catches exceptions from `run()` — it logs the message and +trace to STDERR and exits non-zero — so failures are detectable via `join()` even +without [debug mode](05-output-and-debugging.md). Where STDERR goes depends on +your `$outputTarget`. + +> **Avoid `exit()`/`die()` inside `run()`.** They bypass the runner's normal path: +> the exit code becomes whatever you passed to `exit()` (so `exit(0)` on a failure +> would look like success), and the exception handling above is skipped. Return +> normally for success, and `throw` for failure — let the runner set the code. diff --git a/docs/04-debugging-and-output.md b/docs/04-debugging-and-output.md deleted file mode 100644 index 48ef9e6..0000000 --- a/docs/04-debugging-and-output.md +++ /dev/null @@ -1,172 +0,0 @@ -# 4. Debugging and Output Handling - -`Winter Thread` provides a flexible system for handling output from background processes. -The behavior is controlled by two parameters of `start()`: `$debugMode` and `$outputTarget`. - -> **Why `/dev/null` is the default** -> -> When a parent process opens a pipe (`$outputTarget = null`) but never reads from it, -> the OS buffer (~64 KB) fills up, the child process blocks on `write()`, and eventually -> receives a **Broken pipe** error. This silently kills background jobs. -> -> The default `$outputTarget = '/dev/null'` prevents this entirely: output is discarded -> by the OS without buffering, and the parent needs no lifecycle management of the process. -> Pass `null` explicitly only when you actively read output via `readOutput()` / `readError()`. - ---- - -## Strategy 1: Fire and Forget (default) - -The safest mode for production background jobs. Output is discarded. No pipe is opened, -so the parent process can start a task and immediately release the `Thread` object. - -- **`start()`** — equivalent to `start([], false, '/dev/null')` -- **`$debugMode`**: `false` — PHP errors suppressed in the child. -- **`$outputTarget`**: `'/dev/null'` — output discarded by the OS. - -```php - true])); - } -}); - -$pid = $thread->start(); // safe fire-and-forget -echo "Started background process PID: $pid\n"; -// Thread object can be released here; no Broken pipe risk -``` - ---- - -## Strategy 2: Log to File - -The recommended approach for staging and production when you need a record of what -happened. All output (`echo`, `var_dump`, PHP errors) is appended to the specified file. - -- **`start(true, '/path/to/file.log')`** -- **`$debugMode`**: `true` — PHP errors enabled and visible in the log. -- **`$outputTarget`**: a file path string — output appended to the file. - -```php -start(debugMode: true, outputTarget: $logFile); -echo "PID: $pid — logging to {$logFile}\n"; -``` - -**Reading the log:** - -```bash -tail -f worker.log -``` - -**Expected content:** - -``` -Task started at: 2025-12-19 10:30:00 -Processing... -Warning: Division by zero in ... on line XX -``` - ---- - -## Strategy 3: Interactive / Pipe Mode - -For local development and debugging. The parent reads the child's output in real time. -You **must** pass `null` explicitly and **must** actively poll `readOutput()` / `readError()` -while the process is alive — otherwise the pipe buffer fills and causes a Broken pipe. - -- **`start(true, null)`** -- **`$debugMode`**: `true` — PHP errors enabled. -- **`$outputTarget`**: `null` — output piped to the parent process. - -```php -start(debugMode: true, outputTarget: null); -echo "Interactive session started, PID: $pid\n\n"; - -// IMPORTANT: actively drain the pipe while the process runs -while ($thread->isAlive()) { - $out = $thread->readOutput(); - if ($out !== '') { - echo '[STDOUT] ' . rtrim($out) . "\n"; - } - $err = $thread->readError(); - if ($err !== '') { - echo '[STDERR] ' . rtrim($err) . "\n"; - } - usleep(250_000); -} - -// Drain remaining output after process exits -$out = $thread->readOutput(); -if ($out !== '') { - echo '[STDOUT] ' . rtrim($out) . "\n"; -} -$err = $thread->readError(); -if ($err !== '') { - echo '[STDERR] ' . rtrim($err) . "\n"; -} - -$exitCode = $thread->join(); -echo "\nProcess $pid finished with exit code: $exitCode\n"; -``` - -**Expected output (appearing gradually):** - -``` -Interactive session started, PID: 12345 - -[STDOUT] Step 1... -[STDOUT] Step 2... -[STDOUT] Step 3... -[STDERR] Warning: Custom warning for demo in ... on line XX - -Process 12345 finished with exit code: 0 -``` - ---- - -## Summary - -| Mode | `$outputTarget` | `$debugMode` | Use case | -|------------------------|-------------------|--------------|---------------------------------------| -| Fire and forget | `'/dev/null'` (default) | `false` | Production background jobs | -| Log to file | `'/path/file.log'`| `true` | Staging / production with audit trail | -| Interactive pipe | `null` (explicit) | `true` | Local dev, real-time output reading | diff --git a/docs/05-api-reference.md b/docs/05-api-reference.md deleted file mode 100644 index 52d8ebe..0000000 --- a/docs/05-api-reference.md +++ /dev/null @@ -1,287 +0,0 @@ -# 5. API Reference - -Complete reference for all public classes and methods in Winter Thread. - ---- - -## `Thread` - -The main class. Manages a single background process that executes a `Runnable` task. - -### Constructor - -```php -new Thread( - Runnable $runnable, - string $namespace = '', - ?string $name = null, - ?string $tag = null -) -``` - -| Parameter | Type | Default | Description | -|--------------|------------|---------|-----------------------------------------------------------------------------| -| `$runnable` | `Runnable` | — | The task to execute in the child process. | -| `$namespace` | `string` | `''` | Logical group for process identification (e.g. `"Billing"`). | -| `$name` | `?string` | `null` | Task name. Auto-derived from the class short name when `null`. | -| `$tag` | `?string` | `null` | Instance tag to distinguish runs (e.g. `"job-42"`, `"user-123"`). | - -The namespace, name, and tag appear in the OS process title as: -`WinterThread -> @` - ---- - -### Instance Methods - -#### `start(array $arguments = [], bool $debugMode = false, ?string $outputTarget = '/dev/null'): int` - -Serializes the `Runnable`, launches a child PHP process via `proc_open`, and returns immediately. - -| Parameter | Type | Default | Description | -|-----------------|-----------|----------|-----------------------------------------------------------------------------| -| `$arguments` | `array` | `[]` | Associative array of custom run arguments. Scalar values and booleans only. `true` creates a valueless flag; `false`/`null` are skipped. Available in `run()` via the `$args` parameter. | -| `$debugMode` | `bool` | `false` | `true` enables PHP error reporting in the child process. | -| `$outputTarget` | `?string` | `'/dev/null'` | Where stdout/stderr go. `'/dev/null'` (default) discards output — safe for fire-and-forget, no pipe opened. `null` pipes output to the parent — actively read via `readOutput()`/`readError()` to avoid Broken pipe. A file path appends output to that file. | - -**Returns:** `int` — PID of the child process. - -**Throws:** `ThreadException` — if `proc_open` fails. - -> **Warning:** Passing `null` pipes stdout/stderr to the parent process. If the parent -> never reads them, the OS buffer (~64 KB) fills up and the child blocks on `write()`, -> eventually causing a **Broken pipe** error. Only pass `null` when you actively drain -> output via `readOutput()` / `readError()`; otherwise keep the `'/dev/null'` default. - ---- - -#### `join(int $timeout = 0): ?int` - -Blocks until the child process terminates. - -| Parameter | Type | Default | Description | -|------------|-------|---------|--------------------------------------------------| -| `$timeout` | `int` | `0` | Max seconds to wait. `0` waits indefinitely. | - -**Returns:** -- `int` — exit code (typically `0` for success). -- `null` — timeout reached before termination. -- `-1` — process was never started. - ---- - -#### `isAlive(): bool` - -Checks whether the child process is currently running. - -**Returns:** `true` if running (including paused), `false` otherwise. - ---- - -#### `pause(): bool` - -Pauses the child process by sending `SIGSTOP`. The process remains in the process table -(so `isAlive()` returns `true`) but is suspended by the OS until `resume()` is called. - -**Returns:** `true` if the signal was delivered, `false` if the process is not running. - ---- - -#### `resume(): bool` - -Resumes a paused process by sending `SIGCONT`. - -**Returns:** `true` if the signal was delivered, `false` if the process is not running. - ---- - -#### `interrupt(): bool` - -Sends `SIGINT` to the child process (equivalent to Ctrl+C). The process can catch and handle this signal. - -**Returns:** `true` if the signal was delivered, `false` if the process is not running. - ---- - -#### `terminate(): bool` - -Requests graceful shutdown by sending `SIGTERM`. The child process can catch this signal -to perform cleanup before exiting. If it ignores the signal, the process keeps running. - -**Returns:** `true` if the signal was delivered, `false` if the process is not running. - ---- - -#### `kill(): bool` - -Forcefully terminates the child process by sending `SIGKILL`. Cannot be caught or ignored. -Use as a last resort after `terminate()` fails. - -**Returns:** `true` if the signal was delivered, `false` if the process is not running. - ---- - -#### `readOutput(): string` - -Reads buffered stdout from the child process since the last call. Only works when started -with `$outputTarget = null`. - -**Returns:** `string` — content read from stdout, or `''` if no pipe is open. - ---- - -#### `readError(): string` - -Reads buffered stderr from the child process since the last call. Only works when started -with `$outputTarget = null`. - -**Returns:** `string` — content read from stderr, or `''` if no pipe is open. - ---- - -#### `getPid(): ?int` - -**Returns:** `int` — PID of the child process, or `null` if not yet started. - ---- - -#### `getNamespace(): string` - -**Returns:** `string` — the namespace provided to the constructor. - ---- - -#### `getName(): string` - -**Returns:** `string` — the task name (auto-derived or explicitly set). - ---- - -#### `getTag(): ?string` - -**Returns:** `string|null` — the tag, or `null` if not set. - ---- - -### Static Methods - -#### `Thread::bindPayloadMode(string $mode): void` - -Configures how the serialized `Runnable` payload is delivered to the child process. -Call once during application bootstrap before starting any threads. - -| Constant | Value | Description | -|---|---|---| -| `Thread::PAYLOAD_PIPE` | `'pipe'` | Default. Delivers payload via a stdin pipe. | -| `Thread::PAYLOAD_TEMP_FILE` | `'temp_file'` | Writes payload to a temp file (`0600`), unlinked immediately after `proc_open`. No parent pipe fd. | -| `Thread::PAYLOAD_SHM` | `'shm'` | Writes payload to a System V shared memory segment (`0600`). No parent pipe fd. Requires `ext-shmop`. | - -```php -// Swoole: auto-select best mode at bootstrap -if (extension_loaded('swoole') && \Swoole\Coroutine::getCid() !== -1) { - Thread::bindPayloadMode( - extension_loaded('shmop') - ? Thread::PAYLOAD_SHM - : Thread::PAYLOAD_TEMP_FILE - ); -} -``` - -**Throws:** `ThreadException` — if the mode is unknown, or if `PAYLOAD_SHM` is requested -without `ext-shmop`. - -See [6. Payload Modes](06-payload-modes.md) for full details and security notes. - ---- - -#### `Thread::bindSerSecurity(string $serSecurityKey): void` - -Sets a secret key for signed serialization of closures and anonymous classes via `opis/closure`. -Call once during application bootstrap before creating any threads that use anonymous classes. - -```php -Thread::bindSerSecurity('your-long-secret-key'); -``` - ---- - -#### `Thread::bindRunner(string $runnerScriptPath): void` - -Overrides the path to the runner script (`wExecutor`). Useful for framework integration -where a custom bootstrap is needed in the child process. - -```php -Thread::bindRunner('/app/bootstrap/thread-runner.php'); -``` - ---- - -#### `Thread::bindBinaryPath(string $binaryPath): void` - -Sets the path to the PHP CLI binary. Required when running under PHP-FPM or CGI, where -`PHP_BINARY` points to the FPM handler rather than the CLI executable. - -```php -Thread::bindBinaryPath('/usr/bin/php8.3'); -``` - ---- - -#### `Thread::getRunnerScriptPath(): string` - -**Returns:** `string` — the configured runner script path (or the default `wExecutor`). - ---- - -#### `Thread::getSerSecurity(): ?DefaultSecurityProvider` - -**Returns:** `DefaultSecurityProvider|null` — the Opis/Closure security provider, or `null` if not configured. - ---- - -## `Runnable` - -Interface that every background task must implement. - -```php -interface Runnable -{ - public function run(array $args): void; -} -``` - -| Constraint | Detail | -|---|---| -| **Serializable** | The object is serialized and passed to the child process via stdin. Properties must not contain resources (DB connections, file handles, sockets). Initialize those inside `run()`. | -| **`$args`** | Associative array of custom arguments passed via `Thread::start(['key' => 'value'])`. Keys map to `--arg-=` CLI flags. `true` flags arrive as `true`; string values arrive as strings. | - ---- - -## `Signal` - -Utility class for sending POSIX signals to arbitrary PIDs. - -> **Warning:** Unlike `Thread`, `Signal` does not validate that the PID belongs to your -> process. Due to OS PID reuse, a PID may be reassigned to a different process after the -> original terminates. Use `Thread` methods for reliable lifecycle management; use `Signal` -> only with freshly obtained PIDs. - -### Static Methods - -| Method | Signal | Description | -|---|---|---| -| `Signal::interrupt(int $pid): bool` | `SIGINT` | Sends interrupt (Ctrl+C). | -| `Signal::termination(int $pid): bool` | `SIGTERM` | Requests graceful shutdown. | -| `Signal::close(int $pid): bool` | `SIGHUP` | Sends hangup (reload or exit depending on the process). | -| `Signal::kill(int $pid): bool` | `SIGKILL` | Forces immediate termination. | -| `Signal::wait(int $pid, int $timeout = 10): bool` | — | Polls until the PID is gone or timeout (seconds) is reached. Returns `true` if the process terminated, `false` on timeout. | -| `Signal::interruptAndWait(int $pid, int $timeout = 10): bool` | `SIGINT` | Sends interrupt, then waits. | -| `Signal::terminationAndWait(int $pid, int $timeout = 10): bool` | `SIGTERM` | Sends SIGTERM, then waits. | -| `Signal::closeAndWait(int $pid, int $timeout = 10): bool` | `SIGHUP` | Sends SIGHUP, then waits. | -| `Signal::isProcessRunning(int $pid): bool` | `0` (probe) | Returns `true` if a process with this PID exists. | - ---- - -## `ThreadException` - -Extends `\RuntimeException`. Thrown by `Thread::start()` when `proc_open` fails to launch -the child process (e.g. resource limits, wrong permissions, missing runner script). diff --git a/docs/05-output-and-debugging.md b/docs/05-output-and-debugging.md new file mode 100644 index 0000000..5895321 --- /dev/null +++ b/docs/05-output-and-debugging.md @@ -0,0 +1,141 @@ +# 5. Output & Debugging + +A background process has nowhere to print by default. Winter Thread gives you +three explicit output targets and a debug switch, chosen per `start()` call. + +## Output targets + +The third argument to `start()` (`$outputTarget`) controls where the child's +**STDOUT and STDERR** go. Both streams always go to the **same** place: + +| `$outputTarget` | Behavior | Use case | +|---|---|---| +| `'/dev/null'` *(default)* | output discarded; no pipe opened | fire-and-forget — safe by default | +| `'/path/to/file.log'` | output **appended** to the file (mode `a`) | persistent logging | +| `null` | output piped to the parent | interactive: read via `readOutput()` / `readError()` | + +```php +// Discard (default) +$thread->start(); + +// Log to a file (created if missing, appended if present) +$thread->start(outputTarget: '/var/log/app/report.log'); + +// Pipe to parent and read it +$thread->start(outputTarget: null); +``` + +A few exact behaviors worth knowing: + +- **File mode is append (`a`)**, so multiple workers can point at the same log + without truncating each other, and a restart won't wipe history. You manage + rotation. +- **STDOUT and STDERR share the target.** With a file, both are interleaved into + it; with `null`, they are still two *separate* pipes you read via + `readOutput()` and `readError()`. +- The parent process must be able to **write/create** the file path (permissions, + `open_basedir`). + +### Why `/dev/null` is the default + +When output goes to a pipe (`null`) but nobody reads it, the OS pipe buffer +(typically ~64 KB) fills up and the child's next write **blocks indefinitely** — +or the child receives a *Broken pipe* and dies silently. Either way your +"fire-and-forget" job stalls or vanishes with no trace. + +Defaulting to `/dev/null` means fire-and-forget jobs can **never** hit this: there +is no pipe, so there is nothing to fill. Only choose `null` when the parent +actively drains the pipe in a loop; choose a **file** when you want the output but +aren't going to read it live. + +## Reading piped output + +With `outputTarget: null`, the parent gets two **non-blocking** pipes. Poll them +while the process runs, then drain the tail after it exits: + +```php +$thread->start(outputTarget: null); + +$out = ''; +while ($thread->isAlive()) { + $out .= $thread->readOutput(); // returns whatever is buffered right now + usleep(10_000); // 10 ms — don't busy-spin +} +$out .= $thread->readOutput(); // drain anything written just before exit +$thread->join(); // reap and collect the exit code +``` + +Notes: + +- `readOutput()` / `readError()` return **whatever is currently available** (they + never block); an empty string means "nothing new yet", not "done". +- Read the **tail after the loop** — the worker may write right before exiting. +- These methods return `''` if you started with a file or `/dev/null` target + (there is no pipe to read), and `''` after the handle is reaped or detached. + +> **Important:** never pass `null` unless you drain the pipe in a loop like this. +> A slow or absent reader is exactly the Broken-pipe stall described above. +> +> **In particular, `null` + a bare `join()` can deadlock.** `join()` waits for the +> child to exit but does **not** read the pipes. If the child writes more than the +> ~64 KB buffer, it blocks on `write()` and never exits — so `join()` waits forever. +> Always drain in the loop *before* `join()` (as above), or use a file/`/dev/null` +> target when you won't read. +> +> **Under Swoole**, prefer file output over `null` — the output pipes (fd 1/2) are +> subject to the same `SWOOLE_HOOK_ALL` fd corruption as the payload pipe. The +> `AdaptiveEngine` fixes the *payload* transport automatically, but it cannot fix +> output pipes you explicitly asked for. See +> [8. Payload Transports](08-payload-transports.md#swoole--event-loop-compatibility). + +## Debug mode + +By default the child **suppresses all PHP diagnostics** — `error_reporting(0)`, +`display_errors` off — so a stray notice or warning can never corrupt the payload +channel or pollute your logs. This is the safe production default. + +Enable full error reporting with `debugMode: true`: + +```php +$thread->start(debugMode: true, outputTarget: null); + +$errors = ''; +while ($thread->isAlive()) { + $errors .= $thread->readError(); + usleep(10_000); +} +$errors .= $thread->readError(); +$thread->join(); +``` + +In debug mode the child sets `error_reporting(E_ALL)` and turns on +`display_errors` / `display_startup_errors`, so notices, warnings, and startup +errors are written to STDERR (wherever your `$outputTarget` points). Use it while +developing a task; leave it **off** in production. + +> Debug mode changes *diagnostics only* — it does not change how the task runs or +> how exit codes are produced. + +## Uncaught exceptions are always reported + +This is independent of debug mode. If your `run()` throws and doesn't catch it, +the runner catches it **for you**, writes the exception message **and full stack +trace** to STDERR, and exits with a **non-zero** code. So even with debug off and +output to `/dev/null`, you can still detect failure via the exit code: + +```php +if ($thread->join() !== 0) { + // the task failed — inspect the log file / STDERR target you configured +} +``` + +To capture *why* it failed, point `$outputTarget` at a file (or `null` and drain +it) so the message and trace are preserved. + +## Choosing quickly + +- **Production fire-and-forget** → `/dev/null` (default). Detect failure via exit + code; if you need the reason, log to a **file**. +- **Live progress / interactive** → `null` + a drain loop. +- **Developing a task** → `debugMode: true` + a file or `null` so you see warnings + and traces. diff --git a/docs/06-payload-modes.md b/docs/06-payload-modes.md deleted file mode 100644 index 1b34181..0000000 --- a/docs/06-payload-modes.md +++ /dev/null @@ -1,194 +0,0 @@ -# 6. Payload Modes - -By default, Winter Thread serializes the `Runnable` object and delivers it to the child -process through a **stdin pipe** (`proc_open` descriptor 0). This works perfectly in -standard PHP-FPM and CLI environments. - -However, in environments like **Swoole** where `SWOOLE_HOOK_ALL` intercepts all file -descriptor operations and wraps them in coroutines, the pipe descriptor leaks into -Swoole's internal fd table and causes a cascade failure: - -1. Parent writes payload and calls `fclose($pipe)`. -2. Swoole tries to clean up the fd via `socket_free_defer`, treating it as a socket — gets `EBADF`. -3. The fd stays "dirty" in Swoole's internal table. -4. On the next request the kernel reuses the same fd number for a new pipe. -5. Swoole sees it as already registered → `posix_spawn() failed: Bad file descriptor`. - -Winter Thread solves this with two alternative payload delivery strategies that create -**zero parent-side pipe fds**. - ---- - -## Available Modes - -| Constant | Delivery method | Parent fd | Requires | -|---|---|---|---| -| `Thread::PAYLOAD_PIPE` | stdin pipe (default) | `pipe fd` | — | -| `Thread::PAYLOAD_TEMP_FILE` | temp file as stdin | none | writable `sys_get_temp_dir()` | -| `Thread::PAYLOAD_SHM` | System V shared memory | none | `ext-shmop` | - ---- - -## Configuring the Mode - -Call `Thread::bindPayloadMode()` **once during application bootstrap**, before any threads -are started. The mode is global and applies to all subsequent `Thread::start()` calls. - -```php -use Flytachi\Winter\Thread\Thread; - -Thread::bindPayloadMode(Thread::PAYLOAD_TEMP_FILE); -// or -Thread::bindPayloadMode(Thread::PAYLOAD_SHM); -// or reset to default -Thread::bindPayloadMode(Thread::PAYLOAD_PIPE); -``` - -**Throws** `ThreadException` if: -- The mode string is not one of the three constants. -- `PAYLOAD_SHM` is requested but `ext-shmop` is not loaded. - ---- - -## Mode: `PAYLOAD_TEMP_FILE` - -The payload is written to a temporary file in `sys_get_temp_dir()` before `proc_open` is -called. The file is opened with `0600` permissions and unlinked from the filesystem -**immediately after `proc_open` succeeds**. The child process holds the file descriptor -open and reads the full payload through it; no directory entry remains on disk. - -**No pipe fd is ever created in the parent process.** - -### When to use - -- Running inside **Swoole** coroutines (`SWOOLE_HOOK_ALL` enabled). -- Running inside **ReactPHP**, **Amp**, or any other event loop that hooks file descriptors. -- Any environment where open pipe fds cause issues. - -### Requirements - -- `sys_get_temp_dir()` must be writable (standard on all systems). -- No extra PHP extensions needed. - -### Example: Swoole bootstrap - -```php -start(); -} -``` diff --git a/docs/06-process-control.md b/docs/06-process-control.md new file mode 100644 index 0000000..047d850 --- /dev/null +++ b/docs/06-process-control.md @@ -0,0 +1,296 @@ +# 6. Process Control & Lifecycle + +Once a task is running you have full control over it: query its state, send +signals, wait for it, or explicitly release it. This chapter is precise about +what each method does — including the corner cases — because a pool or supervisor +depends on those exact semantics. + +## Lifecycle at a glance + +``` + pause / resume / interrupt / + terminate / kill (signals) + │ +new Thread(...) ──start()──▶ running ──join()/reap()──▶ finished (reaped) + │ │ │ + not started detach() getExitCode() + getPid()=null (stop tracking) is set here +``` + +Three states, from the parent's point of view: + +- **not started** — constructed but `start()` not called (or it threw). +- **running** — a live child; `isAlive()` is `true`. +- **finished (reaped)** — the child exited *and* the parent collected it; its + `proc_open` resources are freed and `getExitCode()` is set. + +A fourth, deliberate off-ramp is **detached** — you stop tracking a still-running +child (see [`detach()`](#giving-up-ownership-detach)). + +## State + +```php +$thread->getPid(); // ?int — PID, or null before start() +$thread->isAlive(); // bool — is the process still running right now? +$thread->getExitCode(); // ?int — exit code once reaped, else null +``` + +Exact semantics: + +- **`getPid()`** returns the launched PID after `start()`, `null` before. Note in + [detached mode](09-detached-mode.md) this is the *launcher's* ephemeral PID, not + the real worker's. +- **`isAlive()`** reflects live process status (`proc_get_status`). It becomes + `false` once the child exits, and also once you `detach()` (you're no longer + tracking it), and is `false` before `start()`. A **paused** worker (SIGSTOP via + `pause()`) is still `true` — it is suspended, not gone. +- **`getExitCode()`** is `null` until the process is **reaped** (by `join()` or a + successful `reap()`), then holds the integer exit code. ⚠️ If you `detach()` a + process, it is **never** reaped through this handle, so `getExitCode()` stays + `null` forever — detaching trades the exit code for non-blocking release. + +## Signals + +Requires `ext-posix`. Each method sends one POSIX signal and returns `true` if it +was sent, `false` if the process isn't running (or was detached): + +```php +$thread->pause(); // SIGSTOP — suspend (cannot be caught or ignored) +$thread->resume(); // SIGCONT — resume a paused process +$thread->interrupt(); // SIGINT — Ctrl+C equivalent (catchable) +$thread->terminate(); // SIGTERM — graceful stop request (catchable) +$thread->kill(); // SIGKILL — force kill (cannot be caught) +``` + +Guidance: + +- **`pause()` / `resume()`** are handy for throttling — freeze a worker under + memory pressure, resume when clear. `SIGSTOP` can't be blocked, so it always + takes effect. +- **`terminate()`** lets a well-behaved task catch `SIGTERM` (via + `pcntl_signal`/`pcntl_async_signals` *inside* the task) and clean up. This is + the polite way to stop work. +- **`kill()`** is the last resort — unblockable, no cleanup, possible partial + writes. Reach for it only when `terminate()` was ignored. +- Every signal method first checks the process is alive, so calling one on a + finished/detached thread simply returns `false` rather than erroring or hitting + an unrelated PID. + +For signalling by **raw PID** (e.g. a supervisor acting on a PID persisted +elsewhere, or a [detached worker's](09-detached-mode.md) self-reported PID), use +the [`Signal`](../src/Signal.php) helper. It detects **zombie** processes +correctly across Linux (`/proc//status`) and macOS (`ps`), so a +not-yet-reaped dead process is reported as *not running* — see the +[API reference](14-api-reference.md#signal-final-class). Be aware of PID reuse: +only act on freshly obtained PIDs. + +### Handling signals inside a task (graceful shutdown) + +By default SIGTERM, SIGINT, and SIGHUP already **terminate** the worker — so +`terminate()`, `interrupt()`, and `kill()` stop a task out of the box, +with **no signal code in `run()` at all** (the [test suite](15-testing.md) verifies +this against a plain `sleep()` task). You only add a handler when you want a +**graceful** stop: catch the signal, flush/checkpoint/release, and exit cleanly +instead of being killed abruptly mid-work. It is optional, lives **inside your +task**, and needs `ext-pcntl`: + +```php +final class ImportRows implements Runnable +{ + public function run(array $args): void + { + $stop = false; + + // Deliver pending signals between PHP statements (no manual pcntl_signal_dispatch()). + pcntl_async_signals(true); + pcntl_signal(SIGTERM, function () use (&$stop) { $stop = true; }); + pcntl_signal(SIGINT, function () use (&$stop) { $stop = true; }); + + foreach ($this->rows() as $row) { + if ($stop) { + $this->checkpoint(); // flush progress, release locks… + return; // clean exit → exit code 0 + } + $this->process($row); + } + } +} +``` + +Now the parent can ask for a graceful stop and confirm it landed: + +```php +$thread->terminate(); // SIGTERM → the handler sets $stop = true +$exit = $thread->join(5); // give it up to 5s to checkpoint and exit +if ($exit === null) { + $thread->kill(); // it ignored us / is stuck → force it + $thread->join(); +} +``` + +Key points: + +- **Without a handler the worker still stops** — the signal's default action + terminates it, just *abruptly*: no cleanup, and the exit is signal-based (a + non-zero code), not `0`. A handler only changes *how* it stops, not *whether*. +- **`pcntl_async_signals(true)`** is the modern way — handlers fire between + statements without you calling `pcntl_signal_dispatch()` in the loop. It requires + `ext-pcntl` (already a dependency). +- **SIGKILL (`kill()`) and SIGSTOP (`pause()`) cannot be handled** — no cleanup is + possible. Design cleanup around SIGTERM; keep SIGKILL as the last resort. +- **Blocking C calls** (a long `sleep()`, a synchronous DB query) only see the + signal when they return. For tight responsiveness, break long waits into short + chunks and re-check your stop flag. +- A handler that finishes `run()` normally yields **exit code 0**; throw from it if + you want the run recorded as failed. + +## Waiting: `join()` + +Blocks until the process exits, then reaps it and returns the exit code: + +```php +$exit = $thread->join(); // wait forever +$exit = $thread->join(timeout: 5); // wait up to 5s; null on timeout +``` + +- returns the **exit code** (`0` = success) once finished, and reaps the process + as a side effect (so `getExitCode()` is then set); +- returns **`null`** if a positive `$timeout` (in **seconds**) elapses first — the + process is still running, and you can `join()` again or fall back to `reap()`; +- returns **`-1`** if the thread was never started. + +Internally `join()` polls process status every 50 ms; `timeout: 0` (the default) +means no timeout. The timeout is in **whole seconds** — there is no sub-second +granularity. If you need finer control, drive `reap()` in your own loop with a +shorter `usleep()`. + +### `-1` is overloaded — read it carefully + +A worker **killed by a signal** (SIGTERM/SIGINT/SIGKILL with no graceful handler +that exits `0`) never exits normally, so the OS reports no clean exit code: both +`join()` and `getExitCode()` return **`-1`**. That is the *same* `-1` `join()` +returns for a thread that was **never started**. So `-1` means "no clean exit +code", **not** a specific failure value. + +Practical rules: + +- Treat outcomes as **`0` = success**, **anything else = failure**. Don't ascribe + meaning to the exact non-zero number (`-1` for signal death, `1` for a thrown + task or a rejected payload, etc.). +- Tell "still running" from "finished" with `isAlive()`/`reap()`, not the code. +- If you need to know a worker was *signalled*, track that yourself (you sent the + signal), or have the task write its own outcome (a file/DB row) before exiting. + +## Reaping without blocking: `reap()` + +`reap()` is the non-blocking counterpart of `join()`. It collects the process +**only if it has already finished**, and returns immediately otherwise: + +```php +if ($thread->reap()) { + // finished and cleaned up; getExitCode() is now set +} else { + // still running — do something else and check again later +} +``` + +Return value: + +- **`true`** — the process is finished (or was never started / already gone) and + has been reaped; resources are freed, `getExitCode()` is set. +- **`false`** — still running; nothing was done, call again later. + +This is the primitive a **worker pool** loops over — harvesting completed workers +without stalling on the ones still running: + +```php +// keep only the still-running threads each pass +$running = array_filter($running, fn(Thread $t) => !$t->reap()); +``` + +Reaping a finished process is what prevents it from lingering as a **zombie** +(a dead process the OS keeps until its parent collects it). + +## Giving up ownership: `detach()` + +`detach()` stops tracking the process — it **keeps running**, but this `Thread` +no longer manages it. After detaching: + +| Method | Result after `detach()` | +|---|---| +| `isAlive()` | `false` (you're not tracking it) | +| `reap()` | `true` (nothing left to reap here) | +| `getExitCode()` | `null` — **never populated** | +| `pause()`/`kill()`/… | `false` (no live handle) | + +It is **non-blocking**: it closes the parent's pipe fds and drops the `proc_open` +resource **without** calling the blocking `proc_close`. + +```php +$thread->detach(); // "I no longer care about this one" +``` + +⚠️ **`detach()` is not the same as [detached mode](09-detached-mode.md).** Use it +for short-lived fire-and-forget where the parent exits soon. A detached *live* +process, if it later exits under a **long-lived** parent, becomes a **zombie** +until that parent exits — because nothing calls `wait()` on it. For a long-lived +parent that fires and forgets, prefer `start(detached: true)`, which reparents the +worker to init so it is always reaped: + +```php +// short script, don't care about result → detach is fine +$thread->detach(); + +// long-lived FPM/daemon parent, fire-and-forget → detached MODE +$thread->start(detached: true); +``` + +## Automatic cleanup (the destructor) + +Every `Thread`/`ProcessHandle` has a destructor that **avoids leaking zombies** +when the object goes out of scope: + +- if the child has **finished**, it is reaped (non-blocking); +- if it is **still running**, it is *detached* rather than blocking the parent on + `proc_close`. + +So a dropped `Thread` never stalls your parent. But because a still-running child +is detached (not waited on), you should still `join()`/`reap()` explicitly in +long-lived parents — or use detached mode — so cleanup is **deterministic** and +no zombie survives. + +## What happens when the parent exits + +Workers are **independent OS processes**, so the engine does **not** kill them when +your parent ends. Know these behaviors: + +- **Parent exits normally.** Destructors run: finished children are reaped, + still-running ones are *detached* (left running). Those survivors are then + **reparented to init (PID 1)** and reaped there when they finish — they don't + die with the parent. +- **Parent crashes hard** (fatal, or its own SIGKILL). Destructors may not run, but + the children keep running regardless and still reparent to init. Nothing is + force-stopped for you. +- **Ctrl+C / terminal hang-up.** An **attached** child shares the parent's + controlling terminal and process group (no `setsid`), so a terminal `SIGINT` + (Ctrl+C) or `SIGHUP` is delivered to the **whole foreground group** — it hits + attached workers too. A [detached](09-detached-mode.md) worker is in its own + session and is **insulated** from these. + +If you need children to stop **with** the parent, terminate them explicitly (e.g. +`terminate()`/`kill()` each tracked `Thread` in a shutdown handler) — don't rely on +process exit to do it. If you need them to **outlive** the parent cleanly, use +[detached mode](09-detached-mode.md). + +## The non-blocking guarantee + +`reap()`, `detach()` and the destructor are guaranteed **non-blocking on a live +process**. The only blocking call, `proc_close`, is invoked **exclusively on an +already-dead process** (inside `join()`'s finish path and inside `reap()` when the +child has exited). `join()` itself blocks — that is its job — but nothing else +will stall your loop. + +This is precisely what lets a pool poll hundreds of workers in one tight loop +without any of them holding the loop hostage. See +[11. Architecture](11-architecture.md#building-a-pool-on-launcher--processhandle) +for a complete pool example built on this guarantee. diff --git a/docs/07-the-engine.md b/docs/07-the-engine.md new file mode 100644 index 0000000..c7e0b55 --- /dev/null +++ b/docs/07-the-engine.md @@ -0,0 +1,182 @@ +# 7. The Engine + +Everything configurable lives behind a single abstraction: the +[`Engine`](../src/Engine/Engine.php). It decides *how* a task is delivered and +launched — the payload transport, the launcher, the PHP binary and `wRunner` +paths, and the optional signing secret. (Running the task in the worker is a +separate child-side concern; see [Parent-side only](#the-engine-is-parent-side-only).) + +You bind one **once at bootstrap**: + +```php +Thread::bindEngine($engine); +``` + +If you bind nothing, the default [`AdaptiveEngine`](../src/Engine/AdaptiveEngine.php) +is used — so the zero-config case just works. The binding is a process-wide +static shared by every `Thread` in the process. + +## The `Engine` contract + +```php +interface Engine +{ + public function transport(): PayloadTransport; // how the payload is delivered + public function launcher(): Launcher; // how the process is spawned + public function binaryPath(): string; // PHP CLI binary + public function runnerPath(): string; // wRunner bootstrap script + public function security(): ?DefaultSecurityProvider; // payload signing (or null) +} +``` + +Two implementations ship with the library: `AdaptiveEngine` (self-configuring, +the default) and `ManualEngine` (explicit, clean slate). + +## The Engine is parent-side only + +The `Engine` lives entirely in **your process** (the parent). It is used to +`serialize()` the task, choose the `transport()`, build the `launcher()`, resolve +`binaryPath()`/`runnerPath()`, and sign the payload via `security()`. It is **not** +shipped to the child and has **no child-side method** on its interface: running the +task in the worker is the job of a separate +[`AdaptiveRunner`](11-architecture.md), which the `wRunner` bootstrap constructs on +its own. Engine (parent) and Runner (child) are independent — they don't reference +each other; the only thing that crosses the boundary is the payload and a couple of +CLI flags/env vars. + +Two consequences follow directly, and they explain the whole configuration model: + +1. **The secret reaches the child through the environment, not through your bound + object.** When the parent's engine has a secret, the built-in + [`CliLauncher`](11-architecture.md) injects it into the child's environment as + `WINTER_THREAD_SECRET`. The child (`wRunner`) reads that env var **directly** to + build its verifier — so signing works even if the parent used a `ManualEngine` + with an explicit secret; the value is propagated for you. (See + [10. Security](10-security.md) for why env and not argv.) +2. **The child picks its receiving transport from CLI options, not from your bound + transport.** If a `--shmkey` option is present the child reads shared memory; + otherwise it reads STDIN (which serves both the pipe and temp-file transports + identically). This is why parent and child stay consistent without shipping the + whole engine across the boundary. + +> **Implication for custom launchers.** If you replace the `Launcher` (SSH, +> Docker, remote node), *you* become responsible for running the correct `wRunner` +> on the far side, delivering the payload to its STDIN (or shm), and — if you sign +> — forwarding `WINTER_THREAD_SECRET` into the remote environment. The default +> `CliLauncher` does all of this locally. + +## `AdaptiveEngine` — self-configuring (default) + +Detects the environment at construction and picks sensible defaults; every part +is overridable through the constructor. + +```php +new AdaptiveEngine( + secret: null, // else WINTER_THREAD_SECRET env, else no signing + transport: null, // else auto: TempFile under an active Swoole runtime, else Pipe + binaryPath: null, // else the resolved real PHP CLI binary + runnerPath: null, // else the packaged wRunner + launcher: null, // else the default CliLauncher (built from the above) +); +``` + +What it resolves, exactly: + +- **Secret** — the explicit `secret` argument, else the `WINTER_THREAD_SECRET` + environment variable, else `null` (no signing). +- **Transport** — [`TempFileTransport`](08-payload-transports.md) **only when a + Swoole runtime is active** — detected as being inside a coroutine + (`\Swoole\Coroutine::getCid() !== -1`) *or* with runtime hooks enabled + (`\Swoole\Runtime::getHookFlags() !== 0`) — otherwise + [`PipeTransport`](08-payload-transports.md). If the `swoole` extension isn't + loaded at all, it's always Pipe. +- **Binary path** — under a CLI SAPI (`cli`/`cli-server`) it uses `PHP_BINARY` + (the running interpreter); under a non-CLI SAPI (FPM/CGI) it resolves + `PHP_BINDIR . '/php'` if that is executable, else falls back to `'php'` on + `PATH`. This is what makes it work correctly under FPM. +- **Runner path** — the `wRunner` script in the installed package root. +- **Launcher** — if you pass a `launcher`, it is returned **as-is**; otherwise a + `CliLauncher` is built from the resolved binary/runner/transport plus the + child-env (which carries the secret). + +Because it's the default, most applications never touch the engine at all: + +```php +$thread = new Thread(new MyTask()); +$thread->start(); // AdaptiveEngine, fully configured +``` + +`AdaptiveEngine` is a `final readonly class` — immutable once constructed. To +change one aspect, construct a new one with the relevant named argument. + +## `ManualEngine` — explicit (clean slate) + +Detects **nothing**. You set each part with immutable withers (each returns a +*clone* — the original is untouched). A required part left unset **throws** +`ThreadException` when accessed. Predictable, no environment magic. + +```php +Thread::bindEngine( + (new ManualEngine()) + ->withTransport(new TempFileTransport()) + ->withBinaryPath('/usr/bin/php') + ->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner') + ->withSecurity('your-signing-secret') + ->withLauncher(new MyCustomLauncher()) // optional +); +``` + +Which parts are required depends on how the launcher is resolved: + +- **Default launcher path** — `transport`, `binaryPath` and `runnerPath` must all + be set (each getter throws `"ManualEngine: is not configured."` if not). + `secret` is optional (no signing when absent). +- **Custom launcher path** — if you set `withLauncher(...)`, that launcher is + returned as-is and the engine does **not** require `binaryPath`/`runnerPath`/ + `transport` — your launcher owns its own wiring. + +Use `ManualEngine` when you want an explicit, environment-independent config +(reproducible across CLI/FPM/containers) or a custom backend. + +## Injecting a custom launcher + +The `Launcher` is the seam for new backends (Docker, SSH, remote nodes). Provide +a built instance and the engine returns it unchanged; wiring transport/secret/ +`wRunner` into a custom backend is your responsibility (see +[the distinction above](#the-engine-is-parent-side-only)). + +```php +new AdaptiveEngine(launcher: new MySshLauncher(/* host, key, … */)); +(new ManualEngine())->withLauncher(new MyDockerLauncher(/* image, … */)); +``` + +Interfaces are the only extension points that need implementing (`Engine`, +`Launcher`, `Runner`, `PayloadTransport`); `ProcessHandle` and `LaunchSpec` are +concrete value/handle types you consume, not implement. See +[11. Architecture](11-architecture.md). + +## Accessing the engine directly + +Framework code that builds its own pool can reach the launcher and handle without +the `Thread` facade: + +```php +$engine = Thread::engine(); // the bound engine (lazily an AdaptiveEngine) +$launcher = $engine->launcher(); // the parent-side spawn strategy +$handle = $launcher->launch($spec); // a ProcessHandle for a LaunchSpec +``` + +`Thread::engine()` returns the currently bound engine, lazily creating a default +`AdaptiveEngine` the first time if none was bound. + +## Resetting the engine + +`bindEngine()` sets a process-wide static. To go back to defaults, bind a fresh +`AdaptiveEngine`: + +```php +Thread::bindEngine(new AdaptiveEngine()); +``` + +There is no separate "unbind" — rebinding replaces the previous engine for all +subsequent `Thread` operations in the process. diff --git a/docs/08-payload-transports.md b/docs/08-payload-transports.md new file mode 100644 index 0000000..5bf922e --- /dev/null +++ b/docs/08-payload-transports.md @@ -0,0 +1,156 @@ +# 8. Payload Transports + +To run your task in another process, the engine must move the serialized +`Runnable` from the parent to the child. That delivery is a pluggable +**transport**. Three ship with the library, all interchangeable — your task code +never knows which was used, and all three deliver the **exact same bytes**. + +## The three transports + +| Transport | Delivery | Parent pipe fd? | On child's stdin | Extension | +|---|---|---|---|---| +| [`PipeTransport`](../src/Payload/PipeTransport.php) | serialized task written to the child's stdin **pipe** after launch | **yes** | the pipe | — | +| [`TempFileTransport`](../src/Payload/TempFileTransport.php) | task written to a `0600` **temp file** placed on stdin, unlinked right after launch | **none** | the temp file | — | +| [`ShmTransport`](../src/Payload/ShmTransport.php) | task placed in **System V shared memory**, key passed via `--shmkey` | **none** | `/dev/null` | `ext-shmop` | + +- **Pipe** — the default in plain CLI: simplest, nothing on disk, no extra + extension. The parent writes the payload into the pipe *after* `proc_open` and + closes it; the child reads its STDIN to EOF. +- **TempFile** — avoids pipe file descriptors entirely (the key property under + Swoole). The file is created `0600` (owner-only) in the system temp dir and + **unlinked immediately after the process starts** — the child keeps its open fd, + so nothing lingers on disk even though the path is gone. No extension needed. +- **Shm** — also avoids pipes, keeping the payload in **RAM only**. The parent + allocates a `0600` segment, writes the payload, and passes the integer key as + `--shmkey`; the child reads the segment and **deletes it**. Needs `ext-shmop`; + if the extension is missing it throws a clear `ThreadException` (on both the + staging and receiving side) rather than a fatal error. + +### How the child chooses its receiving side + +The parent's chosen transport determines *staging*, but the **child** picks how to +*receive* purely from CLI options: if `--shmkey` is present it reads shared memory, +otherwise it reads STDIN. Because Pipe and TempFile both arrive on STDIN, the +child treats them identically. This options-driven receive is why the engine never +needs to serialize the transport choice itself — parent and child stay consistent +automatically. + +## Choosing a transport + +Bind an engine with the transport you want: + +```php +use Flytachi\Winter\Thread\Engine\AdaptiveEngine; +use Flytachi\Winter\Thread\Payload\TempFileTransport; + +Thread::bindEngine(new AdaptiveEngine(transport: new TempFileTransport())); +``` + +In normal CLI you rarely need to — the default pipe transport is fine. Pick a +different one when: + +- **You run under Swoole** — the `AdaptiveEngine` already switches to TempFile + automatically (below); you only override if you specifically want Shm. +- **You want nothing on disk, ever** — use `ShmTransport` (RAM only). Requires + `ext-shmop`. +- **Your `/tmp` is unusual** (tiny tmpfs, noexec, restricted `open_basedir`) — + Pipe or Shm avoid the temp file. + +### Trade-offs at a glance + +| Concern | Pipe | TempFile | Shm | +|---|---|---|---| +| Extra extension | none | none | `ext-shmop` | +| Touches disk | no | briefly (unlinked at once) | no | +| Uses pipe fds | yes | no | no | +| Swoole-safe payload | no | yes | yes | +| Large payloads | streamed through the pipe buffer | file-sized | one contiguous segment sized to the payload | + +All three are correct for large payloads (a +[dedicated test](15-testing.md) delivers a payload far larger than a pipe buffer +byte-for-byte); they differ only in mechanism and prerequisites. + +## Swoole / event-loop compatibility + +Under **Swoole** with `SWOOLE_HOOK_ALL`, the runtime intercepts stream functions. +Pipe file descriptors created by `proc_open` get captured into Swoole's internal +table, which later causes `Bad file descriptor` errors. The fix is to **not use +pipe fds for the payload** — i.e. use TempFile or Shm. + +The `AdaptiveEngine` handles this for you: it switches to `TempFileTransport` +automatically when it detects an active Swoole runtime — either inside a coroutine +(`\Swoole\Coroutine::getCid() !== -1`) or with runtime hooks enabled +(`\Swoole\Runtime::getHookFlags() !== 0`). No configuration required: + +```php +// Under an active Swoole runtime, this transparently uses TempFile: +$thread = new Thread(new MyTask()); +$thread->start(); +``` + +Two caveats remain under Swoole: + +1. **Output pipes.** The transport fix covers the *payload* (fd 0). If you start + with `outputTarget: null`, the *output* pipes (fd 1/2) are subject to the same + corruption. Prefer **file output** (a path, or `/dev/null`) under Swoole rather + than `null`. See [5. Output & Debugging](05-output-and-debugging.md). +2. **Dispatch from a coroutine.** Swoole also hooks `proc_open` itself + (`SWOOLE_HOOK_PROC`), which requires a coroutine context. Launch tasks from + inside a coroutine — the normal case in a Swoole app. + +> Detection is guarded by `extension_loaded('swoole')`, so none of this touches a +> non-Swoole app: without the extension the engine always uses Pipe. And note the +> switch keys on an **active** runtime — merely having the extension installed but +> dormant does not force TempFile. + +## How a transport works (internally) + +A transport is two cooperating halves across two processes, plus a parent-side +cleanup: + +- **Parent — `stage($payload): StagedPayload`** — prepares delivery and returns a + [`StagedPayload`](../src/Payload/StagedPayload.php) describing the fd-0 + descriptor (`stdinSpec`), any extra CLI args (e.g. `--shmkey=123`), an optional + `pipePayload` to write after launch (pipe transport), an optional + `unlinkAfterOpen` path (temp-file transport), and an opaque `ref` used for + cleanup. +- **Child — `receive($options): string`** — reads the payload back (from STDIN, or + from the shm segment named by `--shmkey`) and returns the serialized bytes. +- **Parent — `cleanup($staged): void`** — releases the temp file or shm segment. + It is a **fallback**: normally the child already consumed/deleted the resource + (temp file unlinked right after launch, shm deleted on read), and `cleanup()` + runs when the handle finishes to catch the case where the child never got that + far. It is always safe to call, even if the resource is already gone. + +The launcher reads the `StagedPayload` **generically** — it doesn't know which +transport produced it — which keeps the **parent side** fully generic. See +[11. Architecture](11-architecture.md). + +## Writing your own transport + +Implement [`PayloadTransport`](../src/Payload/PayloadTransport.php) — `stage`, +`receive`, `cleanup` — and bind it via an engine. **But mind the child side:** the +default [`AdaptiveRunner`](14-api-reference.md#adaptiverunner-final-readonly-class) +receives the payload from **STDIN** (or shared memory when `--shmkey` is present) — +it does **not** call your transport's `receive()`. So two cases differ sharply: + +- **Delivering on the child's stdin (fd 0)** — a different way of putting bytes on + stdin. This works transparently: your `stage()` sets the fd-0 descriptor and the + child reads STDIN as usual; `receive()` is effectively unused. (This is exactly + how the built-in pipe and temp-file transports both work.) +- **Delivering out-of-band** — a Redis key, a TCP socket, a named FIFO: anything + *not* on stdin/shm. `stage()` runs in the parent, but the default runner won't + call your `receive()`, so the child reads an empty STDIN and fails with + *"No payload received"*. To use such a transport you must **also ship a matching + child runner** — your own bootstrap (like `wRunner`) that invokes your + transport's `receive()` — typically launched by a + [custom `Launcher`](14-api-reference.md#launcher-interface). Parent (`Engine`) + and child (`Runner`) are independent by design; see + [11. Architecture](11-architecture.md). + +Two more things to keep in mind: + +- **`stage()` and `receive()` run in different processes**, coordinating only + through the `StagedPayload`'s CLI args or a channel both sides can name. +- Keep the delivery channel **private** (owner-only) — the payload is the + deserialization trust boundary. See [10. Security](10-security.md). diff --git a/docs/09-detached-mode.md b/docs/09-detached-mode.md new file mode 100644 index 0000000..a2be88a --- /dev/null +++ b/docs/09-detached-mode.md @@ -0,0 +1,147 @@ +# 9. Detached Mode + +Detached mode makes fire-and-forget **zombie-free** under a long-lived parent +(an FPM worker, a daemon). Pass `detached: true` to `start()`: + +```php +$thread = new Thread(new SendEmailBatch($ids)); +$thread->start(detached: true); // returns at once; you never join it +``` + +> Don't confuse this with [`detach()`](06-process-control.md#giving-up-ownership-detach): +> `detach()` *abandons tracking* of an ordinary child (which can still become a +> zombie under a long-lived parent); **detached mode** *re-parents* the worker to +> init so it is always reaped. They solve different problems — see +> [the comparison below](#detached-mode-vs-detach). + +## The problem it solves + +When you spawn a task and never `join()`/`reap()` it, the finished child becomes +a **zombie** — a dead process the OS keeps in the table until its parent collects +it (so the parent could still read its exit status). + +For a short-lived CLI script this doesn't matter: the OS reaps everything when the +script exits moments later. But a **long-lived parent** — an FPM worker handling +thousands of requests, or a daemon running for days — accumulates **one zombie per +fired task**, and eventually hits the per-process or system-wide PID limit and can +no longer fork. Detached mode removes the parent from the picture entirely. + +## How it works + +In detached mode the child **daemonizes** with a single `fork` + `setsid`, *after* +it has already received and deserialized the payload: + +``` +parent ──proc_open──▶ launcher process (php wRunner) + │ 1. receive payload → deserialize + verify + │ 2. fork() + ├── launcher → exit(0) ← dies immediately + └── worker + 3. setsid() ← new session, no controlling tty + 4. set process title + 5. run task; exit(code) +``` + +- The **launcher** process exits instantly with code `0`, so the parent reaps it + cheaply — its `join()`/`reap()` returns at once and it never becomes a zombie. +- The **worker** is now orphaned (its parent, the launcher, is gone) and is + therefore **reparented to init (PID 1)**, which reaps it when it finishes. It + never becomes a zombie under *your* parent. +- **`setsid()`** puts the worker in a new session with no controlling terminal, so + terminal-directed signals (SIGINT/SIGHUP from the parent's tty) don't reach it. + +The payload is fully read and deserialized **before** the fork, so no transport +resource (pipe, temp file, shm) is shared across the fork — the worker already +holds the reconstructed `Runnable` in memory. + +Requires `ext-pcntl` (`fork`) and `ext-posix` (`setsid`) — both are already +package dependencies. Crucially, **the fork happens inside the clean `wRunner` +process, not inside your Swoole/FPM parent**, so none of your app's open +descriptors or event loop are duplicated — it is safe even under a Swoole runtime. + +## The returned PID is the launcher's + +`start(detached: true)` returns the **launcher's ephemeral PID** — the process +that immediately exits — **not** the real worker's. Because of that: + +- `getPid()` on the `Thread` is not the worker; signalling through the `Thread` + (`terminate()`, `kill()`, …) won't reach the worker (the launcher is already + gone, so those calls just return `false`). +- The worker has its **own** PID, discoverable only from inside `run()`. + +### Signal control still works — via a self-reported PID + +The pattern — used by frameworks built on this engine — is for the task to +**self-report its real PID** from inside `run()` and to signal that PID with the +[`Signal`](../src/Signal.php) helper: + +```php +public function run(array $args): void +{ + $myPid = getmypid(); + Registry::store($args['job'], $myPid); // e.g. Redis/DB/file + // … long-running work … +} + +// elsewhere, a supervisor: +Signal::terminationAndWait(Registry::pid($jobId)); // SIGTERM + wait +``` + +The engine's control model is **PID-based**, so once you know the worker's PID the +full `Signal` API applies identically to attached and detached workers. `Signal` +also detects zombies correctly, so a just-exited worker reads as not-running. + +## Output with detached mode + +Because the launcher exits immediately and the parent isn't waiting, **don't** use +`outputTarget: null` with detached mode — there is no reader for the pipes. Use +the default `/dev/null`, or point at a **file** so the worker's output (and any +uncaught-exception trace) is preserved: + +```php +$thread->start(detached: true, outputTarget: '/var/log/app/emails.log'); +``` + +## Containers: give PID 1 a reaper + +Reparenting relies on **PID 1 being a real init that reaps orphans**. On a normal +OS that's `systemd`/`launchd`. In a bare container, PID 1 is often *your own app*, +which does **not** reap orphans — so detached workers would reparent to it and pile +up as zombies. + +Fix it by giving the container a reaping init: + +```bash +docker run --init … # tini as PID 1 +``` + +```yaml +# docker-compose +services: + app: + init: true +``` + +With an init present, detached workers reparent to it and are reaped cleanly. This +only matters for **detached** tasks; attached tasks you `join()`/`reap()` yourself +don't need it. + +## Detached mode vs. `detach()` + +| | `start(detached: true)` | `detach()` | +|---|---|---| +| What it does | worker forks + `setsid`, reparents to init | parent stops tracking an ordinary child | +| Zombie under a long-lived parent? | **no** — init reaps it | **yes**, until the parent exits | +| Blocks? | no | no | +| Exit code available? | no (worker is independent) | no (never reaped here) | +| Needs a container init? | yes, if your app is PID 1 | no | +| Use when | fire-and-forget under FPM/daemon | short script that exits soon after | + +## When to use it + +- **Use detached mode** for fire-and-forget under a **long-lived** parent (FPM, + daemons) — the only reliable way to avoid zombie build-up there. +- **Don't bother** for short CLI scripts that exit soon after — the OS reaps + everything on exit; a plain `start()` (optionally `detach()`) is simpler. +- **Don't detach if you need the result** — use `start()` then `join()`/`reap()` + and read the exit code. diff --git a/docs/10-security.md b/docs/10-security.md new file mode 100644 index 0000000..7e34280 --- /dev/null +++ b/docs/10-security.md @@ -0,0 +1,138 @@ +# 10. Security + +Winter Thread moves a **serialized** object from the parent into a worker, where +it is deserialized and run. Deserialization of untrusted data is the classic +vector for PHP object injection, so the engine is built to make the delivery +trustworthy. This chapter states the threat model precisely and tells you exactly +what is and isn't protected. + +## The trust model + +Two facts bound the risk out of the box: + +1. **The payload is produced by your own parent process.** The child only ever + deserializes what your parent serialized and delivered over a **private + channel**: + - `PipeTransport` — a parent→child stdin pipe (not on disk, not shared); + - `TempFileTransport` — a `0600` temp file, readable only by the same OS user, + unlinked the instant the child starts; + - `ShmTransport` — a `0600` System V shared-memory segment, readable only by + the same OS user, deleted on read. + + External/user input never reaches the deserializer unless *you* put it in the + task. +2. **Deserialization goes through `opis/closure` only.** The engine **never** calls + native `unserialize()`. That alone removes the classic native-`unserialize` + gadget-chain surface. `opis/closure` is a hard dependency and additionally + supports **signed** payloads. + +## Signing (recommended in production) + +Set a **secret** and every payload is HMAC-signed by the parent and verified by +the child before any object is constructed. A forged or tampered payload — or one +signed with a different secret — is **rejected**: the worker writes a +deserialization error to STDERR, exits non-zero, and **runs nothing**. + +```php +use Flytachi\Winter\Thread\Engine\AdaptiveEngine; + +// Explicit: +Thread::bindEngine(new AdaptiveEngine(secret: 'a-long-random-secret')); + +// Or via the environment (picked up automatically by AdaptiveEngine): +// WINTER_THREAD_SECRET=a-long-random-secret +``` + +With `ManualEngine`: + +```php +(new ManualEngine())->withSecurity('a-long-random-secret') /* … + other parts */; +``` + +Under the hood the secret builds an `Opis\Closure\Security\DefaultSecurityProvider` +(returned by `Engine::security()`); the parent signs with it in +`Thread::serialize()`, and the child verifies with it in the runner. When the +verification fails, the runner catches the resulting exception (including Opis's +`SecurityException`) and returns a non-zero exit code — a clean rejection, never a +fatal. + +### How the secret reaches the worker + +The worker is a **separate process**, so it needs the same secret to verify the +signature. The `wRunner` bootstrap reads it from its own **environment** +(`WINTER_THREAD_SECRET`) and builds the verifier — and the built-in `CliLauncher` +is what put it there, **never through argv**. This distinction is deliberate and +matters: + +- `/proc//cmdline` (argv) is **world-readable** — a secret there would leak to + any local user running `ps`; +- `/proc//environ` is readable **only by the owning user**. + +So the signing secret is never exposed in `ps`/argv. This env-based propagation is +**load-bearing**, not a convenience: it is how the parent's secret reaches the +independent child worker. (It's also why the secret can't be +"auto-generated and derived on both sides" — anything the child could regenerate +without receiving, an attacker could regenerate too, defeating the signature.) + +> If you write a **custom launcher** (SSH/Docker/remote), you must forward +> `WINTER_THREAD_SECRET` into the remote environment yourself — and keep it out of +> any command line or log — or the remote worker won't be able to verify. + +## What signing does and doesn't protect + +- ✅ **Integrity/authenticity of the payload.** An attacker who can write to the + transport channel (e.g. tamper the temp file in its brief window, or influence a + custom transport) cannot forge a payload that verifies — it is rejected before + construction. +- ✅ **Object-injection defense.** No attacker-controlled object graph is ever + instantiated, because verification precedes deserialization. +- ❌ **It is not encryption.** The payload is signed, not encrypted; on a private + owner-only channel that is the right trade-off, but don't put plaintext secrets + in the task expecting confidentiality from signing. +- ❌ **It doesn't sanitize your own inputs.** If *you* serialize attacker-controlled + data into the task's properties and then trust it in `run()`, that's an + application bug signing can't catch. + +### The unsigned default + +With **no** secret, the payload is still serialized/deserialized through +`opis/closure` (never native `unserialize`), but without cryptographic integrity. +That is acceptable when you fully trust the local channel (default pipe/temp-file/ +shm are all owner-only), and it keeps the zero-config path frictionless. **Turn +signing on in production**, especially if a transport could ever be influenced by +another user or process. + +## The payload is never on the command line + +Only a small, fixed set of **safe flags** is ever placed on the command line: +`--namespace`, `--name`, `--tag`, `--debug`, `--detach`, per-run `--arg-*` values, +and — for shared memory — `--shmkey=`. The serialized task itself **always** +travels through the payload transport (pipe / file / shm), never argv, so task +contents can't leak via `ps`. + +Every component of the command — the binary path, the runner path, the namespace/ +name/tag, each argument key and value, and any transport CLI arg — is escaped with +`escapeshellarg()` before it reaches `proc_open`'s shell. A transport cannot inject +into the shell command, and a hostile argument value cannot break out of its +quoting. + +> ⚠️ **But those flags are still *visible*.** The namespace/name/tag, your per-run +> `--arg-*` **values**, and the `--shmkey` are on the command line, readable by any +> same-user process via `ps` / `/proc//cmdline`. **Never put a secret in +> `start()` arguments or in the process metadata** — put sensitive data in the +> task's **constructor** (it travels in the payload, not argv), and keep the signing +> secret in the environment (where the engine already puts it). The `0600` shm +> segment blocks *other* users, but its key on argv lets same-user processes attach — +> so the shm payload is only as private as your same-uid trust boundary. + +## Recommendations + +- **Set a secret in production** (`WINTER_THREAD_SECRET` or `withSecurity()`) — + long and random. This upgrades the model from "trust the private channel" to + "cryptographically verify every payload." +- **Use the same secret in the parent and everywhere `wRunner` runs.** Locally the + `CliLauncher` handles this; for custom/remote launchers, propagate it via env. +- **Keep tasks serializable and self-contained** — open resources inside `run()`, + and don't smuggle credentials through task properties that end up in the payload. +- **Restrict `proc_open`** to the contexts where you actually spawn processes (via + `disable_functions` elsewhere), so the ability to launch workers is scoped. diff --git a/docs/11-architecture.md b/docs/11-architecture.md new file mode 100644 index 0000000..b9d83ca --- /dev/null +++ b/docs/11-architecture.md @@ -0,0 +1,161 @@ +# 11. Architecture & Internals + +This chapter is for people building *on top of* the engine (pools, schedulers) or +who simply want to know how it works. `Thread` is a thin facade; the real work is +done by a handful of small, single-purpose components split across two processes. + +## The two-process model + +Everything happens in exactly two processes, and they never share objects — only +bytes on a channel and flags on a command line: + +- **Parent** (your app) — serializes the task, stages the payload, and `proc_open`s + the worker. Uses the `Engine` you bound. +- **Child** (`bin: wRunner`) — a clean PHP CLI process that constructs an + **`AdaptiveRunner`**, reads `WINTER_THREAD_SECRET` from its environment to build + the signature verifier, receives the payload, verifies + deserializes it, + optionally daemonizes, and runs the task. + +The two sides are **fully independent**: the parent-side `Engine` and the +child-side `Runner` don't reference each other, and neither is shipped across the +boundary. Only three things cross it: + +- the **payload**, over the chosen transport channel; +- the **secret**, via the `WINTER_THREAD_SECRET` env var (owner-only), read + directly by `wRunner` (see [10. Security](10-security.md)); +- a few **CLI flags** (`--namespace`, `--shmkey`, `--detach`, `--arg-*`), from + which the child also picks its receiving transport (`--shmkey` → shm, else STDIN + — see [8. Payload Transports](08-payload-transports.md)). + +## Component map + +``` +════════════ PARENT ════════════ ═══════ CHILD (bin: wRunner) ═══════ + + Thread (facade) wRunner (thin bootstrap script) + start / join / reap / detach / … │ reads WINTER_THREAD_SECRET (env) + │ asks the Engine ▼ + ┌────── Engine ◀── bindEngine() Runner (interface) + │ AdaptiveEngine (default) └ AdaptiveRunner (child-side default) + │ ManualEngine 1. receive() (shmkey? shm : STDIN) + │ 2. Opis unserialize + verify signature + │ Engine provides (parent-side): 3. detached? fork + setsid + │ • transport(): PayloadTransport 4. set process title + │ • launcher(): Launcher 5. runnable->run(args) + │ • binaryPath() / runnerPath() 6. exit(code) + │ • security() + ▼ PayloadTransport (interface) + Launcher (interface) ├ PipeTransport + └ CliLauncher (proc_open) ├ TempFileTransport + │ launch(LaunchSpec) └ ShmTransport + ▼ + ProcessHandle ◀═══ a Pool drives THIS directly + pid, isAlive, reap, join, + readOutput/Error, signal, detach + + (Engine and Runner are independent — connected only by payload + env + flags) +``` + +## Responsibilities + +| Type | Kind | Mutability | Responsibility | +|---|---|---|---| +| `Runnable` | interface | — | the task contract (`run(array $args)`) | +| `Thread` | facade | mutable | the friendly Java-like API; delegates to the engine | +| `Engine` | interface | — | parent-side: selects/holds transport, launcher, paths, secret | +| `AdaptiveEngine` | class | `readonly` | self-configuring engine (default) | +| `ManualEngine` | class | immutable via withers | explicit, clean-slate engine | +| `Launcher` | interface | — | parent side: spawn a process → `ProcessHandle` | +| `CliLauncher` | class | `readonly` | `proc_open`-based launcher | +| `ProcessHandle` | class | mutable (tracks state) | low-level process control (reap/join/detach/signals/read) | +| `PayloadTransport` | interface | — | parent↔child payload delivery | +| `Pipe`/`TempFile`/`Shm` `Transport` | class | — | the three delivery strategies | +| `Runner` | interface | — | child-side: read payload, deserialize, run (independent of `Engine`) | +| `AdaptiveRunner` | class | `readonly` | default child-side runner (+ detached fork/setsid) | +| `LaunchSpec` | DTO | `readonly` | all launch parameters in one value object | +| `StagedPayload` | DTO | `readonly` | staging result (fd-0 spec, cli args, cleanup ref) | +| `Signal` | class | static | POSIX signal helpers, with zombie detection | +| `ThreadException` | class | — | the library's only exception (`extends RuntimeException`) | + +Interfaces exist **only** at genuine extension points (`Engine`, `Launcher`, +`Runner`, `PayloadTransport`). `ProcessHandle` and the DTOs are concrete types you +*consume*, not implement. + +## A launch, step by step + +1. **`Thread::start()`** guards against a double-start, serializes the `Runnable` + with `opis/closure` (signed if a secret is set), and builds a `LaunchSpec` with + the namespace/name/tag, arguments, debug flag, output target, and detached flag. +2. **`Engine::launcher()->launch($spec)`** asks the transport to `stage()` the + payload, assembles the descriptor set (fd 0 from the staged `stdinSpec`; fd 1/2 + to the output file, or to non-blocking pipes when `output === null`), builds a + fully `escapeshellarg`-escaped command + (`php wRunner --namespace=… --name=… [--tag=…] [--debug] [--detach] [--shmkey=…] [--arg-…]`), + sets the child env (adding `WINTER_THREAD_SECRET` when signing), and `proc_open`s + it. +3. The launcher then writes the pipe payload (pipe transport) or unlinks the temp + file (temp-file transport), sets the output pipes non-blocking (when + `output === null`), **verifies the process actually started**, and returns a + `ProcessHandle`. If anything failed it cleans up the staged resource and throws + `ThreadException`. +4. In the **child**, `wRunner` sets `set_time_limit(0)`, `ignore_user_abort(true)`, + toggles error reporting from the `--debug` flag, builds a verifier from + `WINTER_THREAD_SECRET`, and runs an `AdaptiveRunner`: `receive` → verify + + deserialize → (if `--detach`) `fork`+`setsid` → set process title → + `runnable->run(parsedArgs)` → `exit(code)`. + +## Building a pool on `Launcher` + `ProcessHandle` + +The framework-facing primitive is the launcher and its handle — no `Thread` object +per task. Build a `LaunchSpec` (serialize the task yourself, or reuse one spec +across launches), fan out, and harvest with a single non-blocking loop: + +```php +use Flytachi\Winter\Thread\Engine\AdaptiveEngine; +use Flytachi\Winter\Thread\Launch\ProcessHandle; +use Flytachi\Winter\Thread\LaunchSpec; + +$launcher = (new AdaptiveEngine())->launcher(); + +// launch a batch (payload = the already-serialized Runnable bytes) +$handles = array_map( + fn($job) => $launcher->launch(new LaunchSpec( + payload: $job->serialized(), + namespace: 'Pool', + name: $job->name(), + )), + $jobs +); + +// one non-blocking harvest loop; no zombies, never stalls on a live worker +while ($handles !== []) { + foreach ($handles as $i => $h) { + if ($h->reap()) { // finished → collected + $exit = $h->getExitCode(); + unset($handles[$i]); + } + } + usleep(20_000); // 20 ms between passes +} +``` + +Because `reap()`/`detach()` are **non-blocking on live processes** (see +[6. Process Control](06-process-control.md#the-non-blocking-guarantee)), a single +loop can manage hundreds of workers efficiently: `proc_close` — the one blocking +call — only ever runs on an already-dead process. To bound concurrency, launch in +waves (keep at most *N* handles in flight and only launch more as slots free). +Signal-based control uses each worker's self-reported PID, so the model works +identically for attached and detached workers. + +## Serializing a task outside `Thread` + +`Thread` serializes for you, but a pool that owns its own `LaunchSpec`s can do it +directly with the bound engine's security provider: + +```php +$payload = \Opis\Closure\serialize($runnable, Thread::engine()->security()); +$spec = new LaunchSpec(payload: $payload, name: 'MyTask'); +``` + +The child verifies this exactly as it would a `Thread`-produced payload — the two +paths are byte-compatible. diff --git a/docs/12-patterns.md b/docs/12-patterns.md new file mode 100644 index 0000000..57e8169 --- /dev/null +++ b/docs/12-patterns.md @@ -0,0 +1,186 @@ +# 12. Patterns + +Practical recipes for the common real-world needs, built on the primitives from +the earlier chapters. Each is copy-paste ready; the facts they rely on link back to +the [API reference](14-api-reference.md). + +## A bounded worker pool + +Running a handful of jobs is just "start them all, then `join()` each" (see +[Quickstart](03-quickstart.md)). But firing **hundreds** at once would spawn +hundreds of PHP processes and exhaust RAM/PIDs. The fix is a **bounded pool**: keep +at most *N* workers alive, and launch the next job only as a slot frees. + +The engine is designed for exactly this: [`reap()`](14-api-reference.md#thread-final-class) +is **non-blocking on a live worker**, so one loop can fill slots and harvest +finished workers without ever stalling. + +```php +use Flytachi\Winter\Thread\Thread; + +/** + * @param iterable $jobs tasks to run + * @param int $concurrency max workers alive at once + * @param callable(Thread):void $onDone called when a worker finishes + */ +function runPool(iterable $jobs, int $concurrency, callable $onDone): void +{ + $queue = is_array($jobs) ? $jobs : iterator_to_array($jobs); + $running = []; + + while ($queue !== [] || $running !== []) { + // 1) Fill free slots up to the cap. + while (count($running) < $concurrency && $queue !== []) { + $task = array_shift($queue); + $t = new Thread($task); + $t->start(); // non-blocking + $running[] = $t; + } + + // 2) Harvest finished workers (never blocks on a live one). + foreach ($running as $i => $t) { + if ($t->reap()) { // true → finished and collected + $onDone($t); // inspect $t->getExitCode() + unset($running[$i]); + } + } + $running = array_values($running); + + usleep(20_000); // 20 ms between passes + } +} +``` + +Usage: + +```php +$jobs = array_map(fn($id) => new ProcessOrder($id), $orderIds); + +runPool($jobs, concurrency: 10, onDone: function (Thread $t) { + $code = $t->getExitCode(); + echo $t->getTag() ?? $t->getName(), $code === 0 ? " ok\n" : " FAILED ($code)\n"; +}); +``` + +Notes: + +- **Pick `concurrency` from your resources**, not the job count — often around the + CPU-core count for CPU-bound work, higher for I/O-bound work. Each worker is a + full PHP process. +- The 20 ms sleep keeps the harvest loop from busy-spinning; tune it to your job + durations. +- Framework authors building a reusable pool can drop the `Thread` facade and drive + [`Launcher`](14-api-reference.md#launcher-interface) + + [`ProcessHandle`](14-api-reference.md#processhandle-final-class) directly — same + loop shape. See [11. Architecture](11-architecture.md#building-a-pool-on-launcher--processhandle). + +## Returning a result from a task + +Workers are **isolated processes** — they share no memory with the parent, so you +can't just `return` a value or set a property. Hand the result back through a +channel both sides can reach. Three common choices: + +| Channel | Good for | Notes | +|---|---|---| +| **A file** | anything, simplest | write in `run()`, read after `join()`; use a unique path | +| **A database row / cache key** | structured or queryable results | the worker opens its own connection inside `run()` | +| **A queue / stream** | streaming or many consumers | e.g. Redis list, message broker | + +The file pattern, end to end: + +```php +final class RenderThumbnail implements Runnable +{ + public function __construct(private string $src, private string $resultPath) {} + + public function run(array $args): void + { + $bytes = /* … do the work … */ 12345; + file_put_contents($this->resultPath, json_encode(['src' => $this->src, 'bytes' => $bytes])); + } +} + +$resultPath = tempnam(sys_get_temp_dir(), 'thumb_'); +$t = new Thread(new RenderThumbnail('/img/a.png', $resultPath)); +$t->start(); + +if ($t->join() === 0) { + $result = json_decode(file_get_contents($resultPath), true); + // … use $result … +} +@unlink($resultPath); // clean up when you're done +``` + +Rules of thumb: + +- **Use a unique result path per job** (`tempnam()`), so parallel workers never + collide. +- **Check the exit code first.** A non-zero exit means `run()` failed — the result + file may be missing or partial; don't trust it. +- **Don't put resources in task properties.** Open the DB/cache connection *inside* + `run()`; only serializable values (the `$resultPath`, IDs) belong in the + constructor. See [4. Basic Usage](04-basic-usage.md#serializability--the-one-hard-rule). + +## Fire-and-forget under a long-lived parent + +An FPM worker or daemon that dispatches jobs and never waits must not accumulate +zombies. Use [detached mode](09-detached-mode.md) and log to a file (there's no +parent to drain pipes): + +```php +$thread = new Thread(new SendEmailBatch($ids)); +$thread->start(detached: true, outputTarget: '/var/log/app/mail.log'); +// returns at once; the worker is reparented to init and reaped there +``` + +In a container where your app is PID 1, add a reaping init (`docker run --init` / +`init: true`) so detached workers are collected. See +[9. Detached Mode](09-detached-mode.md#containers-give-pid-1-a-reaper). + +## Nested threads + +A task may itself spawn threads — a `Thread` inside a `run()` works to arbitrary +depth (the test suite exercises three levels). A coordinator job can fan out +sub-jobs and `join()` them: + +```php +final class BuildReport implements Runnable +{ + public function run(array $args): void + { + $parts = array_map(fn($s) => new BuildSection($s), ['sales', 'costs', 'trends']); + $threads = array_map(function ($p) { $t = new Thread($p); $t->start(); return $t; }, $parts); + foreach ($threads as $t) { $t->join(); } + // … stitch the sections together … + } +} +``` + +Caveat: each nesting level is another PHP process, so total concurrency multiplies +— bound it (a pool at each level) if the fan-out is wide. If the *inner* threads +are detached, the same [container-init](09-detached-mode.md#containers-give-pid-1-a-reaper) +rule applies to whichever process is their PID 1. + +## Retry on failure + +The exit code makes retries trivial — re-run until success or a cap: + +```php +function runWithRetry(callable $makeTask, int $attempts = 3): bool +{ + for ($i = 1; $i <= $attempts; $i++) { + $t = new Thread($makeTask()); + $t->start(); + if ($t->join() === 0) { + return true; + } + usleep(100_000 * $i); // simple backoff + } + return false; +} + +$ok = runWithRetry(fn() => new ChargeCard($invoiceId)); +``` + +Make the task **idempotent** (safe to run twice) before retrying side-effecting +work, since a failure might occur after the effect but before exit. diff --git a/docs/13-troubleshooting.md b/docs/13-troubleshooting.md new file mode 100644 index 0000000..1d06888 --- /dev/null +++ b/docs/13-troubleshooting.md @@ -0,0 +1,175 @@ +# 13. Troubleshooting + +Symptom → cause → fix. The messages below are the **exact** strings the library +emits, so you can match what you see. Where a message is written to STDERR, it goes +to your `outputTarget` (a file, or the parent pipes when `null`; nowhere if +`/dev/null`) — so when debugging, start with `outputTarget` set to a file and +`debugMode: true`. + +## Quick index + +| Symptom | Likely cause | Jump to | +|---|---|---| +| `ThreadException` the moment you call `start()` | `proc_open` disabled / bad binary or runner path | [Won't start](#start-throws-threadexception) | +| Task seems to do nothing; exit code non-zero | payload rejected or not a `Runnable` | [Nothing runs](#the-task-runs-but-does-nothing) | +| `failed to deserialize payload` | secret mismatch / tampered payload | [Payload rejected](#payload-rejected) | +| Zombies pile up under a daemon/FPM | long-lived parent never reaps | [Zombies](#zombie-processes-accumulate) | +| Job stalls or vanishes silently | `null` output pipe never drained | [Broken pipe](#task-stalls-or-dies-silently-broken-pipe) | +| `Bad file descriptor` under Swoole | pipe transport under hooks | [Swoole](#bad-file-descriptor-under-swoole) | +| `$args` missing a value you passed | `false`/`null`/non-scalar dropped | [Arguments](#an-argument-is-missing-in-args) | +| `ShmTransport requires ext-shmop` | extension not loaded | [shmop](#shmtransport-requires-ext-shmop) | +| `ManualEngine: … is not configured` | a required part unset | [ManualEngine](#manualengine--is-not-configured) | +| Signals seem ignored | a handler that never exits, or wrong PID | [Signals](#signals-seem-to-do-nothing) | + +## `start()` throws `ThreadException` + +Messages: **"Failed to start the process using proc_open."** or **"Process failed +to start or terminated immediately."** + +Causes and fixes: + +- **`proc_open` is disabled.** Check `disable_functions` in `php.ini`. It must not + list `proc_open`. Verify: `php -r "var_dump(function_exists('proc_open'));"`. +- **Wrong PHP binary** (common under **FPM**). `PHP_BINARY` under FPM is the FPM + binary, not a CLI one. The [`AdaptiveEngine`](07-the-engine.md) resolves a CLI + binary automatically, but if it guessed wrong, set it explicitly: + ```php + Thread::bindEngine((new ManualEngine()) + ->withBinaryPath('/usr/bin/php') + ->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner') + ->withTransport(new \Flytachi\Winter\Thread\Payload\PipeTransport())); + ``` +- **Bad/inaccessible runner path** (phar, relocated vendor, `open_basedir`). Point + `->withRunnerPath()` at a real on-disk `wRunner`. See + [2. Installation](02-installation-and-requirements.md#the-runner-path-wrunner). + +Also: **"Thread is already running; join()/reap() it or create a new Thread before +starting again."** — you called `start()` twice on the same live `Thread`. Reap the +first run, or create a new `Thread`. See +[4. Basic Usage](04-basic-usage.md#one-start-per-thread). + +## The task runs but does nothing + +The process starts, but exits non-zero and your work never happened. Look at STDERR +(set an `outputTarget` file). Likely messages: + +- **"Error: The provided payload is not a valid Runnable object."** — the + serialized object isn't a `Runnable`. Ensure the class `implements Runnable`. +- **"Error: No payload received."** — the child got an empty payload. Usually a + transport/binary mismatch or a custom launcher that didn't deliver stdin. +- **"Uncaught exception in background process: …"** followed by a stack trace — your + `run()` threw. This is your application error; the trace tells you where. Turn on + `debugMode: true` for warnings/notices too. + +## Payload rejected + +Message: **"Error: failed to deserialize payload: …"** with a non-zero exit and no +task code executed. + +- **Secret mismatch.** If you sign payloads, the child verifies with the secret it + received via `WINTER_THREAD_SECRET`. A different (or missing) secret on the child + rejects everything. With the default `CliLauncher` the secret is propagated for + you; with a **custom launcher** you must forward that env var yourself. See + [10. Security](10-security.md#how-the-secret-reaches-the-worker). +- **Non-serializable task.** A property holding a resource (PDO, socket, stream) + can't be serialized. Move it into `run()`. See + [4. Basic Usage](04-basic-usage.md#serializability--the-one-hard-rule). +- **Genuinely tampered payload** — the signature did its job; investigate the + channel. + +## Zombie processes accumulate + +You see `` processes multiplying under a long-lived parent (FPM worker, +daemon). + +- **You never `join()`/`reap()`.** A finished child stays a zombie until the parent + collects it. Either harvest with a [pool loop](12-patterns.md#a-bounded-worker-pool), + or, for true fire-and-forget, use [detached mode](09-detached-mode.md): + `start(detached: true)`. +- **Detached, but your app is PID 1 in a container.** Reparented workers land on + PID 1, which must reap them. Add a reaping init: `docker run --init` or + `init: true` in Compose. See + [9. Detached Mode](09-detached-mode.md#containers-give-pid-1-a-reaper). +- Note: `detach()` (not detached *mode*) also leaves a zombie under a long-lived + parent — that's expected. See + [6. Process Control](06-process-control.md#giving-up-ownership-detach). + +## Task stalls or dies silently (Broken pipe) + +You started with `outputTarget: null` and the job hangs or disappears with no error. + +- **You didn't drain the pipe.** When output goes to a parent pipe that nobody + reads, the ~64 KB OS buffer fills and the child blocks on `write()` (or gets a + *Broken pipe* and dies). Either drain `readOutput()`/`readError()` in a loop, or + don't use `null` — send output to `/dev/null` (default) or a file. See + [5. Output & Debugging](05-output-and-debugging.md#why-devnull-is-the-default). + +## `Bad file descriptor` under Swoole + +Under Swoole with `SWOOLE_HOOK_ALL`, `proc_open` pipe fds get captured by the +runtime. + +- **Payload channel.** The [`AdaptiveEngine`](07-the-engine.md) switches to + `TempFileTransport` automatically when it detects an active Swoole runtime, so the + payload is safe. If you *forced* `PipeTransport`, stop doing that under Swoole. +- **Output pipes.** `outputTarget: null` uses pipes for fd 1/2 too — prefer file + output under Swoole. +- **Dispatch context.** Swoole hooks `proc_open` (`SWOOLE_HOOK_PROC`), which needs a + coroutine — launch from inside one. See + [8. Payload Transports](08-payload-transports.md#swoole--event-loop-compatibility). + +## An argument is missing in `$args` + +You passed something to `start([...])` but it's absent in `run()`'s `$args`. + +- **`false` and `null` are dropped by design**, and non-scalar values (arrays, + objects) are ignored. Only `true` (→ a boolean flag) and other scalars (→ + strings) come through. Put structured data in the **constructor**, not arguments. + See [4. Basic Usage](04-basic-usage.md#arguments). +- Remember values arrive as **strings** (`'42'`, not `42`) — cast as needed. + +## `ShmTransport requires ext-shmop` + +The shared-memory transport needs the `shmop` extension on **both** the parent and +the worker. + +- Install/enable `ext-shmop`, or switch to `PipeTransport`/`TempFileTransport` + (which need no extension). See [8. Payload Transports](08-payload-transports.md). + +## `ManualEngine: … is not configured` + +You bound a `ManualEngine` but didn't set a required part. + +- With the default launcher, `transport`, `binaryPath` and `runnerPath` are all + required. Set them, or use `AdaptiveEngine` (which configures itself). With a + custom `withLauncher(...)`, those three aren't required. See + [7. The Engine](07-the-engine.md#manualengine--explicit-clean-slate). + +## Signals seem to do nothing + +- **Your task installed a handler that never exits.** With **no** handler, + SIGTERM/SIGINT already terminate the worker. But once you call `pcntl_signal(...)` + you *override* that default — so if your handler only sets a flag and the loop + never checks it (or is stuck in a long blocking call), the process appears to + ignore the signal. Fix: actually check the flag / break long waits into chunks, + or send `kill()` (SIGKILL, unblockable) to force it. See + [6. Process Control](06-process-control.md#handling-signals-inside-a-task-graceful-shutdown). +- **Wrong PID in detached mode.** `start(detached: true)` returns the *launcher's* + PID; signalling through the `Thread` won't reach the real worker. Signal the + worker's self-reported PID via [`Signal`](14-api-reference.md#signal-final-class). + See [9. Detached Mode](09-detached-mode.md#signal-control-still-works--via-a-self-reported-pid). +- **`ext-posix` missing.** Signal methods rely on `posix_kill`; without the + extension they can't send signals. + +## Still stuck? + +Turn on maximum visibility and re-run: + +```php +$thread->start(debugMode: true, outputTarget: '/tmp/wt-debug.log'); +$thread->join(); +echo file_get_contents('/tmp/wt-debug.log'); +``` + +`debugMode: true` enables `E_ALL` + `display_errors` in the child, and the log file +captures both STDOUT and STDERR (including any uncaught-exception trace). diff --git a/docs/14-api-reference.md b/docs/14-api-reference.md new file mode 100644 index 0000000..7cc1e47 --- /dev/null +++ b/docs/14-api-reference.md @@ -0,0 +1,475 @@ +# 14. API Reference + +Complete reference for every public type in `Flytachi\Winter\Thread`. Signatures +match the source exactly; each entry lists parameters, return values, exceptions, +and the non-obvious semantics. + +## API at a glance + +The surface splits into two tiers. **Most applications only touch the Public API — +three types.** The Low-level API is for building your own pool / scheduler / +supervisor directly on the raw primitives. + +### Public API — what you use + +| Type | Kind | You use it to… | +|---|---|---| +| [`Runnable`](#runnable-interface) | interface | define a task — put your logic in `run()` | +| [`Thread`](#thread-final-class) | class | start & control one task as a background process | +| [`Engine`](#engine-interface) | interface | configure delivery/execution once at bootstrap | +| [`AdaptiveEngine`](#adaptiveengine-final-readonly-class-default) · [`ManualEngine`](#manualengine-final-class) | class | the two engines — self-configuring / explicit | + +### Low-level & extension API — what you build on + +| Type | Kind | Role | +|---|---|---| +| [`Launcher`](#launcher-interface) · [`CliLauncher`](#clilauncher-final-readonly-class) | interface / class | spawn a process → return a handle | +| [`ProcessHandle`](#processhandle-final-class) | class | drive one process (reap/join/detach/signal/read) | +| [`LaunchSpec`](#launchspec-final-readonly-class) | DTO | all launch parameters in one value object | +| [`PayloadTransport`](#payloadtransport-interface) · [Pipe](#pipetransport) / [TempFile](#tempfiletransport) / [Shm](#shmtransport) | interface / class | payload delivery strategy | +| [`StagedPayload`](#stagedpayload-final-readonly-class) | DTO | the staging result a launcher consumes | +| [`Runner`](#runner-interface) · [`AdaptiveRunner`](#adaptiverunner-final-readonly-class) | interface / class | child-side execution | +| [`Signal`](#signal-final-class) | class | raw-PID POSIX helpers (zombie-aware) | +| [`ThreadException`](#threadexception-class) | class | the library's only exception | + +The four **interfaces** (`Engine`, `Launcher`, `Runner`, `PayloadTransport`) are the +extension points that accept your own implementation; everything else is a concrete +type you consume. + +--- + +## Public API + +### `Runnable` (interface) + +`Flytachi\Winter\Thread\Runnable` — the task contract. + +``` +public function run(array $args): void +``` + +Put your logic in `run()`; it executes in the worker process. + +| Parameter | Type | Description | +|---|---|---| +| `$args` | `array` | per-run values from `start()` — flags arrive as boolean `true`, all other values as strings | + +**Returns** nothing. Signal outcome via the exit code (`return`/normal completion → +`0`; throwing → non-zero) or side effects (file/DB/queue). + +**Contract:** the implementing object **must be serializable** — no live resources +(PDO, sockets, streams) in its properties; open them inside `run()`. An uncaught +exception is caught by the runner, logged to STDERR with a trace, and turned into a +non-zero exit. + +--- + +### `Thread` (final class) + +`Flytachi\Winter\Thread\Thread` — the high-level facade over one process. +**Mutable**; guards against concurrent starts. + +#### `__construct` + +``` +new Thread( + Runnable $runnable, + string $namespace = '', + ?string $name = null, + ?string $tag = null, +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$runnable` | `Runnable` | — | the task to execute | +| `$namespace` | `string` | `''` | logical grouping, shown in the OS process title | +| `$name` | `?string` | `null` | task name; when `null`, the Runnable's short class name (or `'anonymous'`) | +| `$tag` | `?string` | `null` | instance discriminator; shown as `@tag` (defaults to `@runnable` in the title) | + +Constructing does **not** start anything. The process title is +`WinterThread -> @` (only where `cli_set_process_title()` +exists). + +#### Static — engine binding + +| Method | Returns | Description | +|---|---|---| +| `Thread::bindEngine(Engine $engine)` | `void` | set the process-wide engine (call once at bootstrap) | +| `Thread::engine()` | `Engine` | the current engine; lazily creates a default `AdaptiveEngine` if none bound | + +#### `start` + +``` +start( + array $arguments = [], + bool $debugMode = false, + ?string $outputTarget = '/dev/null', + bool $detached = false, +): int +``` + +Serializes the task, launches the process, returns immediately. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$arguments` | `array` | `[]` | per-run args exposed in `run()`'s `$args`. `true` → flag; `false`/`null` → dropped; other scalars → strings; non-scalars ignored | +| `$debugMode` | `bool` | `false` | enable `E_ALL` + `display_errors` in the child | +| `$outputTarget` | `?string` | `'/dev/null'` | `'/dev/null'` discards; a path appends (mode `a`); `null` pipes to the parent for `readOutput()`/`readError()` | +| `$detached` | `bool` | `false` | daemonize (`fork`+`setsid`) for zombie-free fire-and-forget | + +**Returns** `int` — the launched PID (in detached mode, the launcher's ephemeral +PID). **Throws** `ThreadException` if the thread is already alive, or the process +fails to start. + +#### Lifecycle & waiting + +| Method | Returns | Description | +|---|---|---| +| `join(int $timeout = 0)` | `?int` | block (poll 50 ms) until exit; returns the exit code, `null` on timeout (**seconds**; `0` = forever), or `-1` if never started **or** the worker was signal-killed (see [ch. 6](06-process-control.md)). Reaps on completion. | +| `reap()` | `bool` | non-blocking: `true` if finished/absent (and reaped, exit code set), `false` if still running | +| `detach()` | `void` | stop tracking (non-blocking; no `proc_close`). Afterwards `isAlive()`→`false`, `reap()`→`true`, signals→`false`, exit code never collected | + +#### State + +| Method | Returns | Description | +|---|---|---| +| `getPid()` | `?int` | launched PID, or `null` before `start()`; the launcher's PID in detached mode | +| `isAlive()` | `bool` | is the process running now? `false` before start / after finish / after `detach()` | +| `getExitCode()` | `?int` | exit code once reaped (`join`/`reap`); `-1` if the worker was signal-killed; `null` before reaping, and `null` forever after `detach()` | + +#### Signals *(require `ext-posix`; each returns `false` if not running)* + +| Method | Signal | Description | +|---|---|---| +| `pause()` | SIGSTOP | suspend (unblockable) | +| `resume()` | SIGCONT | resume a paused worker | +| `interrupt()` | SIGINT | Ctrl+C equivalent (catchable) | +| `terminate()` | SIGTERM | graceful stop request (catchable) | +| `kill()` | SIGKILL | force kill (unblockable) | + +Each returns `bool` — `true` if the signal was sent. To react gracefully, the task +must install a handler; see +[6. Process Control](06-process-control.md#handling-signals-inside-a-task-graceful-shutdown). + +#### Output *(non-empty only when started with `outputTarget: null`)* + +| Method | Returns | Description | +|---|---|---| +| `readOutput()` | `string` | STDOUT available right now (non-blocking); `''` if none / not piped | +| `readError()` | `string` | STDERR available right now (non-blocking); `''` if none / not piped | + +#### Metadata + +| Method | Returns | Description | +|---|---|---| +| `getNamespace()` | `string` | the namespace | +| `getName()` | `string` | the resolved task name | +| `getTag()` | `?string` | the tag, or `null` | + +--- + +### `Engine` (interface) + +`Flytachi\Winter\Thread\Engine\Engine` — the configuration/strategy root, used +**parent-side only**. It has no child-side method: the worker runs a separate +[`AdaptiveRunner`](#adaptiverunner-final-readonly-class). The parent's `security()` +is propagated to the child via `WINTER_THREAD_SECRET` +([11. Architecture](11-architecture.md)). + +| Method | Returns | Description | +|---|---|---| +| `transport()` | `PayloadTransport` | how the payload is delivered | +| `launcher()` | `Launcher` | how the process is spawned | +| `binaryPath()` | `string` | absolute PHP CLI binary path | +| `runnerPath()` | `string` | absolute `wRunner` script path | +| `security()` | `?DefaultSecurityProvider` | `Opis\Closure\Security\DefaultSecurityProvider` for signing, or `null` when no secret | + +#### `AdaptiveEngine` (final readonly class, default) + +`Flytachi\Winter\Thread\Engine\AdaptiveEngine` — self-configuring; immutable. + +``` +new AdaptiveEngine( + ?string $secret = null, + ?PayloadTransport $transport = null, + ?string $binaryPath = null, + ?string $runnerPath = null, + ?Launcher $launcher = null, +) +``` + +| Parameter | Type | Resolved default when `null` | +|---|---|---| +| `$secret` | `?string` | the `WINTER_THREAD_SECRET` env var, else `null` (no signing) | +| `$transport` | `?PayloadTransport` | `TempFileTransport` under an **active** Swoole runtime, else `PipeTransport` | +| `$binaryPath` | `?string` | CLI SAPI → `PHP_BINARY`; non-CLI → `PHP_BINDIR/php` if executable, else `'php'` | +| `$runnerPath` | `?string` | the packaged `wRunner` | +| `$launcher` | `?Launcher` | a `CliLauncher` built from the resolved binary/runner/transport + child env | + +To change one aspect, construct a new instance (readonly). + +#### `ManualEngine` (final class) + +`Flytachi\Winter\Thread\Engine\ManualEngine` — explicit, clean-slate. Immutable +withers (each returns a clone); an unset **required** part throws `ThreadException` +when accessed. + +| Method | Returns | Description | +|---|---|---| +| `withTransport(PayloadTransport $t)` | `static` | set the transport | +| `withBinaryPath(string $path)` | `static` | set the PHP binary path | +| `withRunnerPath(string $path)` | `static` | set the `wRunner` path | +| `withSecurity(string $secret)` | `static` | enable signing with this secret | +| `withLauncher(Launcher $l)` | `static` | use a custom launcher (bypasses the default) | + +With a custom `withLauncher(...)`, `transport`/`binaryPath`/`runnerPath` are not +required; otherwise all three are. `security()` returns `null` when no secret was +set. + +--- + +## Low-level & extension API + +For pools, schedulers, and custom backends. `Thread` is built on exactly these +pieces — you can drive them directly. + +### `Launcher` (interface) + +`Flytachi\Winter\Thread\Launch\Launcher` — parent-side spawn strategy. + +``` +public function launch(LaunchSpec $spec): ProcessHandle +``` + +| Parameter | Type | Description | +|---|---|---| +| `$spec` | `LaunchSpec` | everything needed to start the process | + +**Returns** `ProcessHandle`. **Throws** `ThreadException` on failure. Implement this +to launch over SSH, in a container, or on a remote node. + +#### `CliLauncher` (final readonly class) + +`Flytachi\Winter\Thread\Launch\CliLauncher` — the default, `proc_open`-based. + +``` +new CliLauncher( + string $binaryPath, + string $runnerPath, + PayloadTransport $transport, + array $childEnv = [], +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$binaryPath` | `string` | — | PHP CLI binary | +| `$runnerPath` | `string` | — | `wRunner` script | +| `$transport` | `PayloadTransport` | — | how the payload is staged/received | +| `$childEnv` | `array` | `[]` | extra child env (e.g. `['WINTER_THREAD_SECRET' => '…']`) | + +Builds a fully `escapeshellarg`-escaped command. Empty `$childEnv` → the child +**inherits** the parent env; non-empty → merged **over** the inherited env. + +--- + +### `ProcessHandle` (final class) + +`Flytachi\Winter\Thread\Launch\ProcessHandle` — the low-level process primitive a +launcher returns. **Mutable**; tracks process/pipes/exit-code state. Not +constructed directly — obtained from `Launcher::launch()`. + +| Method | Returns | Description | +|---|---|---| +| `getPid()` | `int` | the process PID | +| `isAlive()` | `bool` | is the process running now? | +| `join(int $timeout = 0)` | `?int` | block until exit; exit code, `null` on timeout (seconds), or `exitCode`/`-1` if the resource is already gone | +| `reap()` | `bool` | non-blocking; `true` if finished (and reaped) | +| `detach()` | `void` | stop tracking; closes pipes, **no** `proc_close`, no transport cleanup (the child owns it) | +| `getExitCode()` | `?int` | exit code once reaped; `null` after `detach()` | +| `readOutput()` | `string` | non-blocking STDOUT; `''` if no output pipe | +| `readError()` | `string` | non-blocking STDERR; `''` if no output pipe | +| `signal(int $signal)` | `bool` | `posix_kill(pid, signal)` — only if alive; `false` otherwise | + +`reap()`, `detach()` and `__destruct()` are **non-blocking on a live process**; the +blocking `proc_close` runs only on an already-dead process. `__destruct()` reaps a +finished process, or detaches a still-running one — it never blocks the parent. + +--- + +### `LaunchSpec` (final readonly class) + +`Flytachi\Winter\Thread\LaunchSpec` — an immutable bundle of every launch +parameter. Build one directly to drive a launcher without a `Thread`. + +``` +new LaunchSpec( + string $payload, + string $namespace = '', + string $name = 'anonymous', + ?string $tag = null, + array $arguments = [], + bool $debug = false, + ?string $output = '/dev/null', + bool $detached = false, +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$payload` | `string` | — | the already-serialized `Runnable` | +| `$namespace` | `string` | `''` | process-title grouping | +| `$name` | `string` | `'anonymous'` | process-title name | +| `$tag` | `?string` | `null` | instance tag | +| `$arguments` | `array` | `[]` | exposed to the task as `--arg-*` | +| `$debug` | `bool` | `false` | child-side error reporting | +| `$output` | `?string` | `'/dev/null'` | `'/dev/null'` \| file path \| `null` (pipe to parent) | +| `$detached` | `bool` | `false` | daemonize the child | + +All properties are public and readonly. + +--- + +### `PayloadTransport` (interface) + +`Flytachi\Winter\Thread\Payload\PayloadTransport` — delivers the serialized payload +across the process boundary in two halves plus a cleanup. + +| Method | Returns | Runs in | Description | +|---|---|---|---| +| `stage(string $payload)` | `StagedPayload` | parent | prepare delivery (fd-0 spec, CLI args, cleanup ref) | +| `receive(array $options)` | `string` | child | read the payload back (STDIN, or shm via `shmkey`) | +| `cleanup(StagedPayload $staged)` | `void` | parent | release staged resources; safe even if already gone | + +`$options` in `receive()` is `array` — the parsed child CLI options. + +> ⚠️ The default [`AdaptiveRunner`](#adaptiverunner-final-readonly-class) receives +> from **STDIN or shm only** — it does not call a custom transport's `receive()`. A +> transport delivering out-of-band (Redis/TCP/FIFO) therefore also needs a matching +> child runner. See [8. Payload Transports](08-payload-transports.md#writing-your-own-transport). + +#### `PipeTransport` + +`…\Payload\PipeTransport` — payload via the child's stdin **pipe** (written after +launch). No extension. **Not** Swoole-safe (uses a pipe fd). The default in plain +CLI. + +#### `TempFileTransport` + +`…\Payload\TempFileTransport` — payload via a `0600` **temp file** placed on stdin, +unlinked right after launch (child keeps its fd). No extension. **Swoole-safe.** +Throws `ThreadException` if the temp file can't be created/written. + +#### `ShmTransport` + +`…\Payload\ShmTransport` — payload via a `0600` System V **shared-memory** segment; +the integer key is passed as `--shmkey`, and the child reads then deletes it. No +disk, **Swoole-safe.** **Requires `ext-shmop`** — throws `ThreadException` +("ShmTransport requires ext-shmop.") on both `stage()` and `receive()` if missing, +and on allocation failure. + +#### `StagedPayload` (final readonly class) + +`Flytachi\Winter\Thread\Payload\StagedPayload` — the result of `stage()`; the +launcher reads it generically. + +``` +new StagedPayload( + array $stdinSpec, + array $cliArgs = [], + ?string $pipePayload = null, + ?string $unlinkAfterOpen = null, + mixed $ref = null, +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$stdinSpec` | `array` | — | `proc_open` fd-0 descriptor (`['pipe','r']` or `['file',$path,'r']`) | +| `$cliArgs` | `array` | `[]` | extra, already-safe launch args (e.g. `['--shmkey=123']`) | +| `$pipePayload` | `?string` | `null` | written to the pipe after launch (pipe transport) | +| `$unlinkAfterOpen` | `?string` | `null` | unlinked after launch once the child holds its fd (temp-file transport) | +| `$ref` | `mixed` | `null` | opaque cleanup handle (temp path / shm key) passed to `cleanup()` | + +--- + +### `Runner` (interface) + +`Flytachi\Winter\Thread\Runner\Runner` — child-side execution strategy. + +``` +public function execute(array $options): int +``` + +| Parameter | Type | Description | +|---|---|---| +| `$options` | `array` | parsed `wRunner` CLI options (`namespace`, `name`, `tag`, `debug`, `detach`, `shmkey`) | + +**Returns** `int` — the process exit code (`0` success, non-zero failure). + +#### `AdaptiveRunner` (final readonly class) + +`Flytachi\Winter\Thread\Runner\AdaptiveRunner` — the default child-side runner +(driven by `wRunner`). It depends only on a security provider — **not** on the +`Engine` — so the two sides stay independent. + +``` +new AdaptiveRunner(?DefaultSecurityProvider $security = null, mixed $errStream = null) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$security` | `?DefaultSecurityProvider` | `null` | verifies the payload signature; build it from the same secret the parent signed with (`null` = unsigned). `wRunner` builds it from `WINTER_THREAD_SECRET` | +| `$errStream` | `resource\|null` | `null` | where diagnostics are written; defaults to `STDERR` (injectable for tests) | + +`execute()` flow: receive payload (`--shmkey` → shm, else STDIN) → verify + +deserialize via `opis/closure` (rejecting empty, non-`Runnable`, or +unsigned/tampered payloads with a non-zero exit) → optional `fork`+`setsid` +(detached) → set process title → `run()` → exit code. + +> To use a **custom** `Runner`, you replace the child bootstrap: write your own +> script like `wRunner` that constructs your runner (typically launched by a +> [custom `Launcher`](#launcher-interface)). There is no `bindEngine` seam for the +> runner — by design, the child side is independent of the parent's `Engine`. + +--- + +### `Signal` (final class) + +`Flytachi\Winter\Thread\Signal` — static POSIX helpers on a raw PID. +`isProcessRunning()` treats zombies (state `Z`) as **not** running, on both Linux +(`/proc//status`) and macOS (`ps`). + +| Method | Returns | Description | +|---|---|---| +| `isProcessRunning(int $pid)` | `bool` | live (non-zombie) process with this PID exists? | +| `interrupt(int $pid)` | `bool` | send SIGINT (if running) | +| `termination(int $pid)` | `bool` | send SIGTERM (if running) | +| `close(int $pid)` | `bool` | send SIGHUP (if running) | +| `kill(int $pid)` | `bool` | send SIGKILL (if running) | +| `wait(int $pid, int $timeout = 10)` | `bool` | poll until gone; `false` on timeout (seconds) | +| `interruptAndWait(int $pid, int $timeout = 10)` | `bool` | SIGINT then wait | +| `terminationAndWait(int $pid, int $timeout = 10)` | `bool` | SIGTERM then wait | +| `closeAndWait(int $pid, int $timeout = 10)` | `bool` | SIGHUP then wait | + +> **No `pause`/`resume` here.** `Signal` covers only stop/terminate signals. +> Suspend/resume by raw PID is not exposed — use `posix_kill($pid, SIGSTOP)` / +> `posix_kill($pid, SIGCONT)` directly, or the +> [`Thread::pause()`/`resume()`](#thread-final-class) API. + +> ⚠️ Raw PIDs are subject to OS **PID reuse**. Prefer a `Thread`/`ProcessHandle` for +> reliable tracking; use `Signal` only with freshly obtained PIDs (e.g. a +> [detached worker's](09-detached-mode.md) self-reported PID). + +--- + +### `ThreadException` (class) + +`Flytachi\Winter\Thread\ThreadException extends \RuntimeException` — the library's +only exception type. + +Thrown on: launch failure (`proc_open` denied, or the process died immediately), +starting an already-running `Thread`, misconfiguration (an unset `ManualEngine` +part, or `ShmTransport` without `ext-shmop`), and transport staging failures (temp +file / shm allocation). Catch it around `start()` and engine setup. diff --git a/docs/15-testing.md b/docs/15-testing.md new file mode 100644 index 0000000..42a3b3f --- /dev/null +++ b/docs/15-testing.md @@ -0,0 +1,104 @@ +# 15. Testing + +The test suite is organized in **two tiers**, mirroring the `winter-kernel` +layout: a **default** tier that runs anywhere, and a **container** tier for heavy, +environment-specific checks. Suite selection is driven by PHPUnit `testsuite` +definitions in `phpunit.xml` (the default suite is `base` + `working`). + +## Default tier — runs on any machine + +`composer test` runs the `base` and `working` suites. Anything requiring an absent +extension (e.g. `ext-shmop`, `swoole`) self-skips, so it is always green on a plain +PHP install. + +```bash +composer install +composer test # base + working (the default suite) +composer test-base # unit-level class correctness +composer test-working # end-to-end scenarios +composer test-detail # testdox (human-readable) output +``` + +### `base` — class correctness in isolation (`tests/Base`) + +Unit-level tests for each building block: + +| Test | Covers | +|---|---| +| `Engine/EngineTest` | `AdaptiveEngine` detection/overrides & `ManualEngine` withers + unset-part throws | +| `Launch/CliLauncherTest` | command building, escaping, env, start-failure handling | +| `Launch/ProcessHandleTest` | `reap`/`join`/`detach`/destructor semantics & the non-blocking guarantee | +| `LaunchSpecTest` | the launch DTO defaults/values | +| `Payload/PipeTransportTest`, `TempFileTransportTest`, `ShmTransportTest`, `StagedPayloadTest` | each transport's stage/receive/cleanup (shm self-skips without `ext-shmop`) | +| `Runner/AdaptiveRunnerTest` | receive → deserialize/verify → run, and the failure exits | + +### `working` — real end-to-end scenarios (`tests/Working`) + +Spawns actual processes and drives them: + +| Test | Covers | +|---|---| +| `ThreadTest`, `ThreadFacadeTest` | the full `Thread` API, metadata, double-start guard | +| `TransportScenariosTest` | every transport delivering a real task | +| `SignalTest` | `pause`/`resume`/`terminate`/`kill`/`interrupt` behavior | +| `DetachedTest` | detached start returns at once; worker reparents | +| `PoolLoopTest` | the non-blocking `reap()` harvest loop | +| `FailureModesTest` | fault tolerance: bad binary/runner, double-start, throwing tasks, bad payload | +| `WRunnerTest` | the `wRunner` bootstrap end to end | + +## Container tier — heavy & environment-specific + +The `container` suite (`tests/Container`) is **excluded from the default run** and +executed inside Docker, where `/proc`, Swoole, `ext-shmop`, and a reaping init are +all available. + +```bash +tests/run-container.sh # default versions: 8.4 8.5 +tests/run-container.sh 8.4 # a single version +tests/run-container.sh 8.4 8.5 8.6 # a custom list + +# Inside an environment that already has swoole/shmop: +composer test-container # phpunit --testsuite container +``` + +What it covers, per PHP version: + +| Group | Test | Verifies | +|---|---|---| +| **Leak** | `LeakCliTest` | no zombies, no fd growth, no memory growth across many spawns | +| | `FpmScenarioTest` | the long-lived-parent (FPM) scenario stays clean | +| | `SecurityTest` | payload absent from `/proc//cmdline`; unsigned/tampered payloads rejected | +| **Timing** | `OverheadTest` | start-up latency per transport; detached start doesn't block on the task | +| **Swoole** | `SwooleScenariosTest` | payload delivered intact inside a hooked coroutine **and** with the runtime dormant | +| **Payload** | `LargePayloadTest` | a payload far larger than a pipe buffer arrives byte-intact | +| **Load** | `StressTest` | concurrency stress (40 / 100 at once, 300 through a bounded pool) with a printed throughput/fd/zombie summary | +| **Metrics** | `MemoryFootprintTest` | prints a worker RSS report (default build vs a lean `php -n` worker) | +| **Nested** | `NestedThreadTest` | a `Thread` inside a `Thread`, three levels deep | +| **Workload** | `BattleRunTest` | a mixed "battle run" of a dozen heterogeneous jobs against expected results | + +`tests/Fixtures` holds the named task classes these suites reuse (payloads must be +serializable named classes to survive the process boundary cleanly). + +## The container image + +`tests/docker/Dockerfile` builds `php:-cli` with `pcntl`, `posix`, +`shmop`, and `swoole` (plus `procps` for `ps` / `/proc`). The compose file runs the +container with `init: true` so [detached](09-detached-mode.md) workers are reaped +by tini — matching the production guidance for containers. + +## Continuous integration + +`.github/workflows/ci.yml` runs two jobs across a PHP `8.4` / `8.5` matrix: + +- **default** — via `setup-php`; runs code style (`phpcs`) then `composer test`; +- **container** — builds the Docker image (with layer caching) and runs the default + + container suites inside it (`docker run --init`). + +Third-party actions are pinned to commit SHAs for supply-chain safety. + +## Code style + +```bash +composer cs-check # phpcs +composer cs-fix # phpcbf +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..732c044 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,62 @@ +# Winter Thread — Documentation + +Object-oriented background-process control for PHP — a Java-like threading model +over OS processes, **without heavy extensions** (no swoole / parallel / pthreads, +no ZTS build). Just `proc_open` and standard POSIX. + +## Mental model in 30 seconds + +You implement a `Runnable`, wrap it in a `Thread`, and `start()` it. The library +serializes the task, spawns a **fresh PHP CLI process** (`proc_open` → the packaged +`wRunner` bootstrap), and runs your task there — fully isolated from the parent. +You then `join()` for the exit code, `reap()` non-blocking in a loop, or fire and +forget. Everything configurable lives behind a single pluggable `Engine`. + +## Table of contents + +1. [Introduction](01-introduction.md) — what it is, the no-heavy-ext philosophy, when (not) to use it +2. [Installation & Requirements](02-installation-and-requirements.md) — deps, FPM, containers, the `wRunner` path +3. [Quickstart](03-quickstart.md) — a complete run-in-parallel-and-collect example +4. [Basic Usage](04-basic-usage.md) — `Runnable`, `Thread`, `start()`/`join()`, arguments, exit codes +5. [Output & Debugging](05-output-and-debugging.md) — output targets, the Broken-pipe trap, debug mode +6. [Process Control & Lifecycle](06-process-control.md) — signals, graceful shutdown, `reap()`, `detach()` +7. [The Engine](07-the-engine.md) — `AdaptiveEngine` / `ManualEngine`, parent-vs-child, custom launchers +8. [Payload Transports](08-payload-transports.md) — pipe / temp-file / shm, Swoole compatibility +9. [Detached Mode](09-detached-mode.md) — zombie-free fire-and-forget, container init +10. [Security](10-security.md) — signed payloads, the trust model, object-injection defense +11. [Architecture & Internals](11-architecture.md) — components, the two-process model, building a pool +12. [Patterns](12-patterns.md) — bounded pool, returning results, nested threads, retries +13. [Troubleshooting](13-troubleshooting.md) — symptom → cause → fix +14. [API Reference](14-api-reference.md) — every public class and method, parameters & returns +15. [Testing](15-testing.md) — the two-tier suite & CI + +## Quick start + +```php +use Flytachi\Winter\Thread\Runnable; +use Flytachi\Winter\Thread\Thread; + +class MyTask implements Runnable { + public function run(array $args): void { /* heavy work in a clean process */ } +} + +$thread = new Thread(new MyTask()); +$thread->start(); // fire-and-forget (output → /dev/null) +$thread->join(); // or wait for the exit code +``` + +## Key facts to internalize + +- **One task = one fresh PHP process.** Great for work larger than the ~few-ms + spawn cost; wrong for millions of microtasks. ([1](01-introduction.md)) +- **`/dev/null` is the default output** — piping to an unread parent (`null`) + risks a Broken-pipe stall. ([5](05-output-and-debugging.md)) +- **`reap()`/`detach()` never block on a live worker** — the basis for a pool + loop. ([6](06-process-control.md), [12](12-patterns.md)) +- **`Engine` is parent-side only; the child runs a separate `AdaptiveRunner`.** + The signing secret reaches the child via the `WINTER_THREAD_SECRET` env var. + ([7](07-the-engine.md), [10](10-security.md)) +- **`detach()` ≠ detached mode.** Only `start(detached: true)` (fork + setsid) + is zombie-free under a long-lived parent. ([9](09-detached-mode.md)) + +New here? Start with the [Introduction](01-introduction.md). diff --git a/phpunit.xml b/phpunit.xml index 37a6609..d48890b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,33 @@ + stopOnFailure="false" + defaultTestSuite="default"> - - tests + + + tests/Base + tests/Working + + + + tests/Base + + + tests/Working + + + + + tests/Container @@ -17,14 +37,4 @@ - - diff --git a/src/Engine/AdaptiveEngine.php b/src/Engine/AdaptiveEngine.php new file mode 100644 index 0000000..fb148d6 --- /dev/null +++ b/src/Engine/AdaptiveEngine.php @@ -0,0 +1,129 @@ +secret = $secret ?? (getenv('WINTER_THREAD_SECRET') ?: null); + $this->transport = $transport ?? self::detectTransport(); + $this->binaryPath = $binaryPath ?? self::detectBinaryPath(); + $this->runnerPath = $runnerPath ?? (dirname(__DIR__, 2) . '/wRunner'); + $this->launcher = $launcher; + } + + public function transport(): PayloadTransport + { + return $this->transport; + } + + public function launcher(): Launcher + { + // Custom launcher injected → use it as-is (caller owns its wiring). + // Otherwise build the default CliLauncher from the resolved config. + return $this->launcher + ?? new CliLauncher($this->binaryPath, $this->runnerPath, $this->transport, $this->childEnv()); + } + + public function binaryPath(): string + { + return $this->binaryPath; + } + + public function runnerPath(): string + { + return $this->runnerPath; + } + + public function security(): ?DefaultSecurityProvider + { + return $this->secret !== null ? new DefaultSecurityProvider(secret: $this->secret) : null; + } + + /** @return array */ + private function childEnv(): array + { + if ($this->secret !== null) { + return ['WINTER_THREAD_SECRET' => $this->secret]; + } + // No signing: if the parent's environment carries an ambient + // WINTER_THREAD_SECRET, neutralize it — otherwise the child inherits it, + // builds a verifier, and rejects our (unsigned) payloads. + return getenv('WINTER_THREAD_SECRET') !== false ? ['WINTER_THREAD_SECRET' => ''] : []; + } + + private static function detectTransport(): PayloadTransport + { + if (extension_loaded('swoole') && self::swooleRuntimeActive()) { + return new TempFileTransport(); + } + return new PipeTransport(); + } + + /** + * true if we are inside a coroutine OR Swoole runtime hooks are enabled + * (both corrupt pipe fds under SWOOLE_HOOK_ALL). + */ + private static function swooleRuntimeActive(): bool + { + if (class_exists('\Swoole\Coroutine') && \Swoole\Coroutine::getCid() !== -1) { + return true; + } + if (class_exists('\Swoole\Runtime') && method_exists('\Swoole\Runtime', 'getHookFlags')) { + return \Swoole\Runtime::getHookFlags() !== 0; + } + return false; + } + + private static function detectBinaryPath(): string + { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server') { + return PHP_BINARY ?: 'php'; + } + $candidate = PHP_BINDIR . '/php'; + return is_executable($candidate) ? $candidate : 'php'; + } +} diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php new file mode 100644 index 0000000..d214bb5 --- /dev/null +++ b/src/Engine/Engine.php @@ -0,0 +1,65 @@ +withTransport(new TempFileTransport()) + * ->withBinaryPath('/usr/bin/php') + * ->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner') + * ->withSecurity('your-signing-secret') + * ->withLauncher(new MyCustomLauncher()) // optional: custom backend + * ); + * ``` + * + * @see Engine + * @see AdaptiveEngine + */ +final class ManualEngine implements Engine +{ + private ?PayloadTransport $transport = null; + private ?string $binaryPath = null; + private ?string $runnerPath = null; + private ?string $secret = null; + private ?Launcher $launcher = null; + + public function withTransport(PayloadTransport $transport): static + { + $clone = clone $this; + $clone->transport = $transport; + return $clone; + } + + public function withLauncher(Launcher $launcher): static + { + $clone = clone $this; + $clone->launcher = $launcher; + return $clone; + } + + public function withBinaryPath(string $binaryPath): static + { + $clone = clone $this; + $clone->binaryPath = $binaryPath; + return $clone; + } + + public function withRunnerPath(string $runnerPath): static + { + $clone = clone $this; + $clone->runnerPath = $runnerPath; + return $clone; + } + + public function withSecurity(string $secret): static + { + $clone = clone $this; + $clone->secret = $secret; + return $clone; + } + + public function transport(): PayloadTransport + { + return $this->transport ?? throw new ThreadException('ManualEngine: transport is not configured.'); + } + + public function binaryPath(): string + { + return $this->binaryPath ?? throw new ThreadException('ManualEngine: binaryPath is not configured.'); + } + + public function runnerPath(): string + { + return $this->runnerPath ?? throw new ThreadException('ManualEngine: runnerPath is not configured.'); + } + + public function security(): ?DefaultSecurityProvider + { + return $this->secret !== null ? new DefaultSecurityProvider(secret: $this->secret) : null; + } + + public function launcher(): Launcher + { + // Custom launcher injected → use it as-is (caller owns its wiring), so + // binaryPath/runnerPath/transport need not be configured in that case. + if ($this->launcher !== null) { + return $this->launcher; + } + if ($this->secret !== null) { + $childEnv = ['WINTER_THREAD_SECRET' => $this->secret]; + } else { + // Neutralize an ambient WINTER_THREAD_SECRET the child would inherit, so + // unsigned payloads aren't rejected by a stray verifier. + $childEnv = getenv('WINTER_THREAD_SECRET') !== false ? ['WINTER_THREAD_SECRET' => ''] : []; + } + return new CliLauncher($this->binaryPath(), $this->runnerPath(), $this->transport(), $childEnv); + } +} diff --git a/src/Launch/CliLauncher.php b/src/Launch/CliLauncher.php new file mode 100644 index 0000000..ec60861 --- /dev/null +++ b/src/Launch/CliLauncher.php @@ -0,0 +1,107 @@ + $childEnv */ + public function __construct( + private string $binaryPath, + private string $runnerPath, + private PayloadTransport $transport, + private array $childEnv = [], + ) { + } + + public function launch(LaunchSpec $spec): ProcessHandle + { + $staged = $this->transport->stage($spec->payload); + + $descriptors = [0 => $staged->stdinSpec]; + if ($spec->output !== null) { + $descriptors[1] = ['file', $spec->output, 'a']; + $descriptors[2] = ['file', $spec->output, 'a']; + } else { + $descriptors[1] = ['pipe', 'w']; + $descriptors[2] = ['pipe', 'w']; + } + + $env = $this->childEnv === [] ? null : array_merge(getenv(), $this->childEnv); + $pipes = []; + $process = proc_open($this->buildCommand($spec, $staged), $descriptors, $pipes, null, $env); + + if (!is_resource($process)) { + $this->transport->cleanup($staged); + throw new ThreadException('Failed to start the process using proc_open.'); + } + + if ($staged->pipePayload !== null) { + fwrite($pipes[0], $staged->pipePayload); + fclose($pipes[0]); + } + if ($staged->unlinkAfterOpen !== null) { + @unlink($staged->unlinkAfterOpen); + } + if ($spec->output === null) { + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + } + + $status = proc_get_status($process); + if (!$status || $status['running'] !== true) { + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + proc_close($process); + $this->transport->cleanup($staged); + throw new ThreadException('Process failed to start or terminated immediately.'); + } + + return new ProcessHandle($process, $pipes, $status['pid'], $this->transport, $staged); + } + + private function buildCommand(LaunchSpec $spec, StagedPayload $staged): string + { + $args = [ + '--namespace=' . escapeshellarg($spec->namespace), + '--name=' . escapeshellarg($spec->name), + ]; + if ($spec->tag !== null) { + $args[] = '--tag=' . escapeshellarg($spec->tag); + } + if ($spec->debug) { + $args[] = '--debug'; + } + if ($spec->detached) { + $args[] = '--detach'; + } + foreach ($staged->cliArgs as $cliArg) { + // Defense-in-depth: escape even though the only current cliArg is + // --shmkey=. Never let a transport inject into the shell command. + $args[] = escapeshellarg($cliArg); + } + foreach ($spec->arguments as $key => $value) { + if (!is_scalar($value) && !is_null($value)) { + continue; + } + if ($value === true) { + $args[] = '--arg-' . escapeshellarg((string) $key); + } elseif ($value !== null && $value !== false) { + $args[] = '--arg-' . escapeshellarg((string) $key) . '=' . escapeshellarg((string) $value); + } + } + + return escapeshellarg($this->binaryPath) . ' ' + . escapeshellarg($this->runnerPath) . ' ' + . implode(' ', $args); + } +} diff --git a/src/Launch/Launcher.php b/src/Launch/Launcher.php new file mode 100644 index 0000000..8014706 --- /dev/null +++ b/src/Launch/Launcher.php @@ -0,0 +1,34 @@ +pid; + } + + public function isAlive(): bool + { + if (!is_resource($this->process)) { + return false; + } + return proc_get_status($this->process)['running']; + } + + public function join(int $timeout = 0): ?int + { + if (!is_resource($this->process)) { + return $this->exitCode ?? -1; + } + $start = time(); + while (true) { + $status = proc_get_status($this->process); + if (!$status['running']) { + return $this->finish($status['exitcode']); + } + if ($timeout > 0 && (time() - $start) >= $timeout) { + return null; + } + usleep(50_000); + } + } + + public function reap(): bool + { + if (!is_resource($this->process)) { + return true; + } + $status = proc_get_status($this->process); + if ($status['running']) { + return false; + } + $this->finish($status['exitcode']); + return true; + } + + public function detach(): void + { + if (!is_resource($this->process)) { + return; + } + $this->closePipes(); + // No proc_close (it blocks on a live child); no transport cleanup (child owns it). + $this->process = null; + $this->detached = true; + } + + public function getExitCode(): ?int + { + return $this->exitCode; + } + + public function readOutput(): string + { + return isset($this->pipes[1]) && is_resource($this->pipes[1]) + ? (string) stream_get_contents($this->pipes[1]) + : ''; + } + + public function readError(): string + { + return isset($this->pipes[2]) && is_resource($this->pipes[2]) + ? (string) stream_get_contents($this->pipes[2]) + : ''; + } + + public function signal(int $signal): bool + { + return $this->isAlive() && posix_kill($this->pid, $signal); + } + + public function __destruct() + { + if ($this->detached || !is_resource($this->process)) { + return; + } + if (!$this->reap()) { + $this->detach(); + } + } + + private function finish(int $exitCode): int + { + $this->exitCode = $exitCode; + $this->closePipes(); + proc_close($this->process); + $this->process = null; + $this->transport->cleanup($this->staged); + return $exitCode; + } + + private function closePipes(): void + { + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + $this->pipes = []; + } +} diff --git a/src/LaunchSpec.php b/src/LaunchSpec.php new file mode 100644 index 0000000..7dad4fb --- /dev/null +++ b/src/LaunchSpec.php @@ -0,0 +1,41 @@ + $arguments Per-run arguments, exposed to the task as `--arg-*`. + * @param bool $debug Enable child-side error reporting. + * @param string|null $output Output target: `'/dev/null'` (discard), `null` + * (pipe to parent for readOutput/readError), or a file path. + * @param bool $detached Daemonize the child (fork + setsid) for zombie-free + * fire-and-forget under a long-lived parent. + */ + public function __construct( + public string $payload, + public string $namespace = '', + public string $name = 'anonymous', + public ?string $tag = null, + public array $arguments = [], + public bool $debug = false, + public ?string $output = '/dev/null', + public bool $detached = false, + ) { + } +} diff --git a/src/Payload/PayloadTransport.php b/src/Payload/PayloadTransport.php new file mode 100644 index 0000000..b23c592 --- /dev/null +++ b/src/Payload/PayloadTransport.php @@ -0,0 +1,53 @@ + $options Parsed CLI options (e.g. `shmkey`). + * @return string The serialized Runnable, ready to deserialize. + */ + public function receive(array $options): string; + + /** + * Parent side: release any resources staged by {@see stage()} (temp file or + * shared-memory segment). Safe to call even if the child already cleaned up. + */ + public function cleanup(StagedPayload $staged): void; +} diff --git a/src/Payload/PipeTransport.php b/src/Payload/PipeTransport.php new file mode 100644 index 0000000..3955ecd --- /dev/null +++ b/src/Payload/PipeTransport.php @@ -0,0 +1,31 @@ +ref)) { + return; + } + $shm = @shmop_open($staged->ref, 'a', 0, 0); + if ($shm !== false) { + shmop_delete($shm); + } + } +} diff --git a/src/Payload/StagedPayload.php b/src/Payload/StagedPayload.php new file mode 100644 index 0000000..940cfa2 --- /dev/null +++ b/src/Payload/StagedPayload.php @@ -0,0 +1,36 @@ + $stdinSpec proc_open descriptor for fd 0 (e.g. `['pipe', 'r']`). + * @param array $cliArgs Extra, already-safe launch arguments (e.g. `--shmkey=123`). + * @param string|null $pipePayload Payload written to the stdin pipe after launch (pipe transport). + * @param string|null $unlinkAfterOpen Temp file unlinked once the child holds its fd (temp-file transport). + * @param mixed $ref Opaque cleanup handle (temp path / shm key) for `cleanup()`. + */ + public function __construct( + public array $stdinSpec, + public array $cliArgs = [], + public ?string $pipePayload = null, + public ?string $unlinkAfterOpen = null, + public mixed $ref = null, + ) { + } +} diff --git a/src/Payload/TempFileTransport.php b/src/Payload/TempFileTransport.php new file mode 100644 index 0000000..05b092a --- /dev/null +++ b/src/Payload/TempFileTransport.php @@ -0,0 +1,48 @@ +ref) && is_file($staged->ref)) { + @unlink($staged->ref); + } + } +} diff --git a/src/Runner/AdaptiveRunner.php b/src/Runner/AdaptiveRunner.php new file mode 100644 index 0000000..5eb99e9 --- /dev/null +++ b/src/Runner/AdaptiveRunner.php @@ -0,0 +1,154 @@ +errStream ?? STDERR; + } + + public function execute(array $options): int + { + $payload = $this->receiveTransport($options)->receive($options); + if ($payload === '') { + fwrite($this->stderr(), "Error: No payload received.\n"); + return 1; + } + + // opis/closure is a hard dependency, so deserialization always goes through + // it — never native unserialize(). With a configured secret it verifies the + // HMAC signature, rejecting forged/tampered payloads (guards Object Injection). + try { + $runnable = \Opis\Closure\unserialize($payload, $this->security); + } catch (\Throwable $e) { + // Includes Opis SecurityException for unsigned/tampered payloads when a + // secret is configured — reject cleanly instead of a fatal error. + fwrite($this->stderr(), 'Error: failed to deserialize payload: ' . $e->getMessage() . "\n"); + return 1; + } + + // The serialized string is no longer needed once the object is rebuilt; + // free it before running the task (matters for large payloads). + unset($payload); + + if (!$runnable instanceof Runnable) { + fwrite($this->stderr(), "Error: The provided payload is not a valid Runnable object.\n"); + return 1; + } + + if (isset($options['detach'])) { + $this->daemonize(); + } + + $this->setProcessTitle($options, $runnable); + + try { + $runnable->run($this->parseArgs()); + return 0; + } catch (\Throwable $e) { + fwrite($this->stderr(), 'Uncaught exception in background process: ' . $e->getMessage() . "\n"); + fwrite($this->stderr(), $e->getTraceAsString() . "\n"); + return 1; + } + } + + /** Read side is options-driven: shmkey → shm, otherwise stdin (pipe/tempfile identical). */ + private function receiveTransport(array $options): PipeTransport|ShmTransport + { + return isset($options['shmkey']) ? new ShmTransport() : new PipeTransport(); + } + + private function daemonize(): void + { + $pid = pcntl_fork(); + if ($pid === -1) { + fwrite($this->stderr(), "Error: fork failed for detached mode.\n"); + exit(1); + } + if ($pid > 0) { + // Launcher process L: exit immediately so the parent reaps it cheaply. + exit(0); + } + // Worker process W: new session, no controlling terminal, reparented to init. + if (posix_setsid() === -1) { + fwrite($this->stderr(), "Error: setsid failed for detached mode.\n"); + exit(1); + } + } + + private function setProcessTitle(array $options, Runnable $runnable): void + { + if (!function_exists('cli_set_process_title')) { + return; + } + $namespace = isset($options['namespace']) ? ($options['namespace'] . ' ') : ''; + $tag = $options['tag'] ?? 'runnable'; + if (isset($options['name'])) { + $name = $options['name']; + } else { + $class = get_class($runnable); + $pos = strrpos($class, '\\'); + $name = $pos === false ? $class : substr($class, $pos + 1); + } + cli_set_process_title("WinterThread {$namespace}-> {$name}@{$tag}"); + } + + /** @return array */ + private function parseArgs(): array + { + $argv = $_SERVER['argv'] ?? []; + $args = []; + foreach ($argv as $arg) { + if (!is_string($arg) || !str_starts_with($arg, '--arg-')) { + continue; + } + $content = substr($arg, 6); + if (str_contains($content, '=')) { + [$key, $value] = explode('=', $content, 2); + $args[$key] = $value; + } else { + $args[$content] = true; + } + } + return $args; + } +} diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php new file mode 100644 index 0000000..665e100 --- /dev/null +++ b/src/Runner/Runner.php @@ -0,0 +1,31 @@ + $options Parsed CLI options passed to the child + * (e.g. `namespace`, `name`, `tag`, + * `debug`, `detach`, `shmkey`). + * @return int The process exit code: 0 on success, non-zero on failure. + */ + public function execute(array $options): int; +} diff --git a/src/Signal.php b/src/Signal.php index ca7376c..293ae6c 100644 --- a/src/Signal.php +++ b/src/Signal.php @@ -1,5 +1,7 @@ start()` works out of the box. * * --- - * ### Example 1: Using a dedicated class + * ### Example * - * ```php + * ``` * class MyTask implements Runnable { - * public function run(array $args): void { - * // Long-running task, e.g., video processing - * sleep(10); - * } + * public function run(array $args): void { sleep(10); } * } * * $thread = new Thread(new MyTask(), 'Processing', 'VideoTask', 'job-123'); - * $pid = $thread->start(); // output goes to /dev/null by default (safe for fire-and-forget) - * echo "Task started with PID: $pid\n"; - * - * // Wait for the task to complete - * $exitCode = $thread->join(); - * echo "Task finished with exit code: $exitCode\n"; + * $pid = $thread->start(); // fire-and-forget, output -> /dev/null + * $exitCode = $thread->join(); // wait for completion * ``` * - * --- - * ### Example 2: Using an anonymous class (if Opis/Closure is installed) - * - * ```php - * // This requires the 'opis/closure' package to be installed. - * Thread::bindSerSecurity('your-secret-key'); - * - * $thread = new Thread(new class implements Runnable { - * public function run(array $args): void { - * file_put_contents('output.log', 'Email batch sent at ' . date('Y-m-d H:i:s')); - * } - * }); + * Custom configuration is done once at bootstrap via {@see Thread::bindEngine()}: * - * $thread->start(); // fire-and-forget: safe by default, output discarded - * // The main script can continue without waiting * ``` - * --- - * - * Key features: - * - Executes any `Runnable` task in the background. - * - Provides a rich API for process control (start, join, pause, resume, terminate, kill). - * - Supports optional, secure serialization of closures via Opis/Closure. - * - Enables process identification in the OS via customizable process titles. + * Thread::bindEngine( + * (new ManualEngine()) + * ->withTransport(new TempFileTransport()) + * ->withBinaryPath('/usr/bin/php') + * ->withSecurity('your-secret') + * ); + * ``` * * @package Flytachi\Winter\Thread - * @version 1.0 + * @version 2.0 * @author Flytachi * @see \Flytachi\Winter\Thread\Runnable - * @see \Flytachi\Winter\Thread\ThreadException + * @see \Flytachi\Winter\Thread\Engine\Engine */ final class Thread { - /** - * The task to be executed in the separate process. - * Must be an object that implements the Runnable interface. - * @var Runnable - */ - private Runnable $runnable; - - /** - * The Process ID (PID) of the child process. - * This value is null until the start() method is successfully called. - * @var int|null - */ - private ?int $pid = null; - - /** - * A logical grouping for the process, used for identification and monitoring. - * @var string - */ - private string $namespace; - - /** - * The specific name of the task being executed. - * Used for process identification. e.g. - * @var string - */ + private ?ProcessHandle $handle = null; private string $name; - /** - * An optional, user-defined tag for distinguishing between instances of the same task. - * @var string|null - */ - private ?string $tag = null; - - /** - * The internal resource handle for the running process, returned by proc_open(). - * This handle is used to get the status of and close the process. - * It should not be manipulated directly. - * @var resource|null - */ - private $processHandle = null; + private static ?Engine $engine = null; /** - * @var resource[] - */ - private array $processPipes = []; - - /** - * The path to the runner script that executes the task. - * Can be overridden via the bindRunner() static method. - * @var string - */ - private static string $runnerScriptPath; - - public const PAYLOAD_PIPE = 'pipe'; - public const PAYLOAD_TEMP_FILE = 'temp_file'; - public const PAYLOAD_SHM = 'shm'; - - private static ?string $serSecurityKey = null; - private static ?string $binaryPath = null; - private static string $payloadMode = self::PAYLOAD_PIPE; - private static int $shmSeq = 0; - - private ?int $shmKey = null; - - - /** - * Constructs a new Thread instance. - * - * @param Runnable $runnable The task object to be executed in the new process. - * @param string $namespace A logical grouping for the process (e.g., "Billing", "Notifications"). - * @param string|null $name The specific name for this task. If null, it will be auto-generated - * from the Runnable's class name. - * @param string|null $tag An optional tag to distinguish this specific process instance - * (e.g., "user-123", "batch-5"). + * @param Runnable $runnable The task to execute in the new process. + * @param string $namespace A logical grouping for the process. + * @param string|null $name Specific name; auto-derived from the Runnable class if null. + * @param string|null $tag Optional tag distinguishing this instance. */ public function __construct( - Runnable $runnable, - string $namespace = '', + private readonly Runnable $runnable, + private readonly string $namespace = '', ?string $name = null, - ?string $tag = null + private readonly ?string $tag = null, ) { - $this->runnable = $runnable; - $this->namespace = $namespace; - $this->tag = $tag; if ($name === null) { $reflection = new \ReflectionClass($runnable); - if ($reflection->isAnonymous()) { - $this->name = 'anonymous'; - } else { - $this->name = $reflection->getShortName(); - } + $this->name = $reflection->isAnonymous() ? 'anonymous' : $reflection->getShortName(); } else { $this->name = $name; } } /** - * Gets the Process ID (PID) of the child process. - * - * @return int|null The PID, or null if the process has not been started. - */ - public function getPid(): ?int - { - return $this->pid; - } - - /** - * Gets the namespace of the process. - * - * @return string The logical namespace. - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * Gets the name of the task. - * - * @return string The specific name of the task. + * Binds the engine used by all threads. Replaces the default AdaptiveEngine. + * Call once at application bootstrap. */ - public function getName(): string + public static function bindEngine(Engine $engine): void { - return $this->name; + self::$engine = $engine; } /** - * Gets the optional tag of the process instance. - * - * @return string|null The user-defined tag, or null if not set. + * Returns the active engine, lazily creating a default AdaptiveEngine if none was bound. */ - public function getTag(): ?string + public static function engine(): Engine { - return $this->tag; + return self::$engine ??= new AdaptiveEngine(); } /** - * Starts the execution of the Runnable task in a new background process. - * - * This method serializes the Runnable object, launches a new PHP process, - * and passes the serialized task to it for execution. It offers flexible - * options for handling the output and passing custom arguments for each run. + * Starts the Runnable in a new background process. * - * @param array $arguments Custom arguments for this specific run. These are passed - * to the child process and can be read using getopt() - * within the Runnable's run() method (prefixed with 'arg-'). - * @param bool $debugMode If true, enables debug mode. PHP errors from the child - * process are reported, and output is piped to the parent - * for real-time reading. - * @param string|null $outputTarget The destination for the process's standard output and error. - * - `'/dev/null'` (default): Output is discarded. Safe for - * fire-and-forget background tasks where the parent does not - * read output. No pipe is opened, so there is no Broken pipe risk. - * - `null`: Output is piped to the parent process. Use only - * when the parent actively reads via readOutput()/readError(), - * otherwise the pipe buffer fills and the child gets a Broken pipe. - * - `'/path/to/file.log'`: Output is appended to the specified file. + * @param array $arguments Custom per-run arguments (read via getopt as --arg-*). + * @param bool $debugMode Enable child error reporting. + * @param string|null $outputTarget '/dev/null' (default), null (pipe to parent), or a file path. + * @param bool $detached Daemonize the child (fork + setsid); zombie-free. * - * @return int The Process ID (PID) of the newly created background process. - * - * @throws ThreadException If the process fails to start, for example, due to system - * resource limits or incorrect permissions. + * @return int The PID of the launched process. + * @throws ThreadException If the process fails to start. */ - public function start(array $arguments = [], bool $debugMode = false, ?string $outputTarget = '/dev/null'): int - { - if (function_exists('\Opis\Closure\serialize')) { - $payload = \Opis\Closure\serialize( - $this->runnable, - self::getSerSecurity() + public function start( + array $arguments = [], + bool $debugMode = false, + ?string $outputTarget = '/dev/null', + bool $detached = false, + ): int { + if ($this->handle !== null && $this->handle->isAlive()) { + throw new ThreadException( + 'Thread is already running; join()/reap() it or create a new Thread before starting again.', ); - } else { - $payload = serialize($this->runnable); } - $tmpPath = null; - $this->shmKey = null; - - if (self::$payloadMode === self::PAYLOAD_TEMP_FILE) { - $tmpPath = $this->writePayloadToTempFile($payload); - $descriptorSpec = [0 => ['file', $tmpPath, 'r']]; - } elseif (self::$payloadMode === self::PAYLOAD_SHM) { - $this->shmKey = $this->writePayloadToShm($payload); - $descriptorSpec = [0 => ['file', '/dev/null', 'r']]; - } else { - $descriptorSpec = [0 => ['pipe', 'r']]; - } - - if ($outputTarget !== null) { - $descriptorSpec[1] = ['file', $outputTarget, 'a']; - $descriptorSpec[2] = ['file', $outputTarget, 'a']; - } else { - $descriptorSpec[1] = ['pipe', 'w']; - $descriptorSpec[2] = ['pipe', 'w']; - } - - $this->processHandle = proc_open( - $this->buildCommand($arguments, $debugMode, $this->shmKey), - $descriptorSpec, - $this->processPipes + $engine = self::engine(); + $spec = new LaunchSpec( + payload: $this->serialize($engine), + namespace: $this->namespace, + name: $this->name, + tag: $this->tag, + arguments: $arguments, + debug: $debugMode, + output: $outputTarget, + detached: $detached, ); - - if (!is_resource($this->processHandle)) { - if ($tmpPath !== null) { - @unlink($tmpPath); - } - if ($this->shmKey !== null) { - $this->cleanupShm($this->shmKey); - $this->shmKey = null; - } - throw new ThreadException('Failed to start the process using proc_open.'); - } - - if (self::$payloadMode === self::PAYLOAD_PIPE) { - fwrite($this->processPipes[0], $payload); - fclose($this->processPipes[0]); - } elseif (self::$payloadMode === self::PAYLOAD_TEMP_FILE) { - // child already holds stdin fd open; remove directory entry immediately - unlink($tmpPath); - } - // PAYLOAD_SHM: child reads shm by key passed via CLI arg, no stdin fd - - if ($outputTarget === null) { - stream_set_blocking($this->processPipes[1], false); - stream_set_blocking($this->processPipes[2], false); - } - - $status = proc_get_status($this->processHandle); - if (!$status || $status['running'] !== true) { - $this->closePipes(); - proc_close($this->processHandle); - $this->processHandle = null; - if ($this->shmKey !== null) { - $this->cleanupShm($this->shmKey); - $this->shmKey = null; - } - throw new ThreadException('Process failed to start or terminated immediately.'); - } - - $this->pid = $status['pid']; - return $this->pid; - } - - /** - * Checks if the child process is currently running. - * - * This method checks the status of the process handle returned by proc_open(). - * It does not send any signals to the process. - * - * @return bool True if the process is running, false otherwise (e.g., it has terminated, - * was never started, or the handle is invalid). - */ - public function isAlive(): bool - { - if (!is_resource($this->processHandle)) { - return false; - } - - $status = proc_get_status($this->processHandle); - return $status['running']; - } - - /** - * Waits for the child process to complete its execution. - * - * This method blocks the execution of the current script until the child process - * has finished. It is the equivalent of Java's `Thread.join()`. - * - * @param int $timeout The maximum number of seconds to wait for the process to terminate. - * If 0, it will wait indefinitely. Default: 0. - * - * @return int|null The exit code of the child process (e.g., 0 for success). - * Returns null if the timeout is reached before the process terminates. - * Returns -1 if the process was already terminated before the call. - */ - public function join(int $timeout = 0): ?int - { - if (!is_resource($this->processHandle)) { - return -1; - } - - $startTime = time(); - - while (true) { - $status = proc_get_status($this->processHandle); - - if (!$status['running']) { - $this->closePipes(); - proc_close($this->processHandle); - $this->processHandle = null; - if ($this->shmKey !== null) { - $this->cleanupShm($this->shmKey); - $this->shmKey = null; - } - return $status['exitcode']; - } - - if ($timeout > 0 && (time() - $startTime) >= $timeout) { - return null; - } - - usleep(50_000); // 0.05 seconds - } + $this->handle = $engine->launcher()->launch($spec); + return $this->handle->getPid(); } /** - * Reads the standard output (STDOUT) from the process. - * This method only works if the thread was started with $outputTarget = null. - * - * @return string The content from STDOUT since the last read. + * Gets the PID of the child process, or null if not started. */ - public function readOutput(): string + public function getPid(): ?int { - if (isset($this->processPipes[1])) { - return (string) stream_get_contents($this->processPipes[1]); - } - return ''; + return $this->handle?->getPid(); } /** - * Reads the standard error (STDERR) from the process. - * This method only works if the thread was started with $outputTarget = null. - * - * @return string The content from STDERR since the last read. + * Checks whether the child process is currently running. */ - public function readError(): string - { - if (isset($this->processPipes[2])) { - return (string) stream_get_contents($this->processPipes[2]); - } - return ''; - } - - /** - * Pauses the execution of the child process by sending a SIGSTOP signal. - * - * This is a "hard" pause that cannot be ignored by the child process. - * The process can be resumed later using the resume() method. - * Note: This functionality requires the `posix` PHP extension. - * - * @return bool True if the signal was sent successfully, false otherwise - * (e.g., the process is not running, or the `posix` extension is not available). - */ - public function pause(): bool + public function isAlive(): bool { - if ($this->isAlive() && $this->pid) { - return posix_kill($this->pid, SIGSTOP); - } - return false; + return $this->handle?->isAlive() ?? false; } /** - * Resumes the execution of a paused (via SIGSTOP) process by sending a SIGCONT signal. - * - * Note: This functionality requires the `posix` PHP extension. - * - * @return bool True if the signal was sent successfully, false otherwise - * (e.g., the process is not running, or the `posix` extension is not available). + * Blocks until the child terminates. Returns its exit code, null on timeout, + * or -1 if never started. */ - public function resume(): bool - { - if ($this->isAlive() && $this->pid) { - return posix_kill($this->pid, SIGCONT); - } - return false; - } - - /** - * Sends an interrupt signal (SIGINT) to the child process. - * - * This is typically equivalent to pressing Ctrl+C in the terminal. - * Like SIGTERM, this is a "soft" signal that the process can catch and handle. - * Note: This functionality requires the `posix` PHP extension. - * - * @return bool True if the signal was sent successfully, false otherwise. - */ - public function interrupt(): bool + public function join(int $timeout = 0): ?int { - if ($this->isAlive() && $this->pid) { - return posix_kill($this->pid, SIGINT); + // Explicit null-handle check: ProcessHandle::join() legitimately returns + // null on timeout, which a `?? -1` fallback would wrongly turn into -1. + if ($this->handle === null) { + return -1; } - return false; + return $this->handle->join($timeout); } /** - * Requests the child process to terminate gracefully by sending a SIGTERM signal. - * - * This is a "soft" termination request. The child process can catch this signal - * to perform cleanup operations (e.g., close files, save state) before exiting. - * If the process ignores this signal, it will continue to run. - * Note: This functionality requires the `posix` PHP extension. - * - * @return bool True if the signal was sent successfully, false otherwise. + * Non-blocking reap: collects the child if it has finished (freeing resources + * and preventing a zombie). Returns true if finished/absent, false if still running. */ - public function terminate(): bool + public function reap(): bool { - if ($this->isAlive() && $this->pid) { - return posix_kill($this->pid, SIGTERM); - } - return false; + return $this->handle?->reap() ?? true; } /** - * Forcefully terminates the child process by sending a SIGKILL signal. - * - * WARNING: This is a "hard" kill. The signal cannot be caught, blocked, or ignored - * by the child process. The process will be terminated immediately by the OS, - * without any chance for cleanup (e.g., saving data or closing connections). - * Use this as a last resort when terminate() or interrupt() fail. - * Note: This functionality requires the `posix` PHP extension. - * - * @return bool True if the signal was sent successfully, false otherwise. + * Stops tracking the child (non-blocking). See {@see ProcessHandle::detach()}. */ - public function kill(): bool + public function detach(): void { - if ($this->isAlive() && $this->pid) { - return posix_kill($this->pid, SIGKILL); - } - return false; + $this->handle?->detach(); } /** - * Constructs the complete command-line string to execute the runner script. - * - * This internal method assembles the full command, including: - * 1. The PHP executable path. - * 2. The path to the runner script. - * 3. System arguments for process identification (--namespace, --name, --tag). - * 4. The debug flag (--debug), if enabled. - * 5. Any custom user-provided arguments for the specific run (prefixed with --arg-). - * - * All arguments are properly escaped to prevent shell injection vulnerabilities. - * - * @param array $arguments An associative array of custom arguments for this specific run. - * Keys become argument names, and values become their values. - * A value of `true` creates a valueless flag. - * @param bool $debugMode If true, the --debug flag is added, enabling - * detailed error reporting in the child process. - * - * @return string The fully constructed and escaped command string, ready for execution. + * Returns the exit code once the process has been reaped, or null otherwise. */ - private function buildCommand(array $arguments, bool $debugMode, ?int $shmKey = null): string + public function getExitCode(): ?int { - $phpExecutable = self::getPhpBinaryPath(); - $runnerScript = self::getRunnerScriptPath(); - - // Base args - $baseArgs = [ - '--namespace=' . escapeshellarg($this->namespace), - '--name=' . escapeshellarg($this->name), - ]; - - if ($this->tag !== null) { - $baseArgs[] = '--tag=' . escapeshellarg($this->tag); - } - if ($debugMode) { - $baseArgs[] = '--debug'; - } - if ($shmKey !== null) { - $baseArgs[] = '--shmkey=' . escapeshellarg($shmKey); - } - - // Custom args - $customArgs = []; - foreach ($arguments as $key => $value) { - if (!is_scalar($value) && !is_null($value)) { - continue; - } - if ($value === true) { - $customArgs[] = '--arg-' . escapeshellarg($key); - } elseif ($value !== null && $value !== false) { - $customArgs[] = '--arg-' . escapeshellarg($key) . '=' . escapeshellarg((string)$value); - } - } - - $allArgs = array_merge($baseArgs, $customArgs); - return escapeshellarg($phpExecutable) . ' ' - . escapeshellarg($runnerScript) . ' ' - . implode(' ', array_filter($allArgs)); + return $this->handle?->getExitCode(); } /** - * Closes any open process pipes. + * Reads STDOUT (only when started with $outputTarget = null). */ - private function closePipes(): void + public function readOutput(): string { - foreach ($this->processPipes as $pipe) { - if (is_resource($pipe)) { - fclose($pipe); - } - } - $this->processPipes = []; + return $this->handle?->readOutput() ?? ''; } /** - * Writes the serialized payload to a temporary file with restricted permissions. - * - * Used instead of a pipe when PAYLOAD_TEMP_FILE mode is active (e.g. under Swoole). - * The caller must unlink the file after proc_open() succeeds. - * - * @throws ThreadException If the file cannot be created or written. + * Reads STDERR (only when started with $outputTarget = null). */ - private function writePayloadToTempFile(string $payload): string + public function readError(): string { - $tmpPath = tempnam(sys_get_temp_dir(), '__wtr_thread_'); - if ($tmpPath === false) { - throw new ThreadException('Failed to create temporary file for payload.'); - } - chmod($tmpPath, 0600); - if (file_put_contents($tmpPath, $payload) === false) { - unlink($tmpPath); - throw new ThreadException('Failed to write payload to temporary file.'); - } - return $tmpPath; + return $this->handle?->readError() ?? ''; } /** - * Writes the serialized payload to a System V shared memory segment. - * - * Retries up to 5 times on key collision. The child process is responsible - * for deleting the segment after reading; join() deletes it as a fallback - * if the child exits without cleanup (e.g. on crash). - * - * @throws ThreadException If ext-shmop is unavailable or allocation fails. + * Pauses the child via SIGSTOP. */ - private function writePayloadToShm(string $payload): int + public function pause(): bool { - $size = strlen($payload); - for ($i = 0; $i < 5; $i++) { - $key = abs(crc32(uniqid('__wtr_thread_', true) . (++self::$shmSeq))); - $shm = @shmop_open($key, 'n', 0600, $size); - if ($shm !== false) { - shmop_write($shm, $payload, 0); - return $key; - } - } - throw new ThreadException('Failed to allocate shared memory segment.'); + return $this->handle?->signal(SIGSTOP) ?? false; } /** - * Attempts to delete a shared memory segment by key. Safe to call even if - * the segment was already deleted by the child process. + * Resumes a paused child via SIGCONT. */ - private function cleanupShm(int $key): void + public function resume(): bool { - if (!extension_loaded('shmop')) { - return; - } - $shm = @shmop_open($key, 'a', 0, 0); - if ($shm !== false) { - shmop_delete($shm); - } + return $this->handle?->signal(SIGCONT) ?? false; } /** - * Configures how the serialized payload is delivered to the child process. - * - * Use PAYLOAD_TEMP_FILE when running inside Swoole coroutines to avoid fd - * corruption caused by SWOOLE_HOOK_ALL intercepting pipe descriptors. - * Call this once at application bootstrap before starting any threads. - * - * ```php - * if (extension_loaded('swoole') && \Swoole\Coroutine::getCid() !== -1) { - * Thread::bindPayloadMode(Thread::PAYLOAD_TEMP_FILE); - * } - * ``` - * - * @param string $mode One of Thread::PAYLOAD_PIPE, Thread::PAYLOAD_TEMP_FILE, or Thread::PAYLOAD_SHM. - * @throws ThreadException If an unknown mode is provided, or if PAYLOAD_SHM is requested without ext-shmop. + * Sends SIGINT to the child. */ - public static function bindPayloadMode(string $mode): void + public function interrupt(): bool { - if (!in_array($mode, [self::PAYLOAD_PIPE, self::PAYLOAD_TEMP_FILE, self::PAYLOAD_SHM], true)) { - throw new ThreadException("Unknown payload mode: {$mode}"); - } - if ($mode === self::PAYLOAD_SHM && !extension_loaded('shmop')) { - throw new ThreadException('ext-shmop is required for PAYLOAD_SHM mode.'); - } - self::$payloadMode = $mode; + return $this->handle?->signal(SIGINT) ?? false; } /** - * Creates a security provider for Opis/Closure serialization. - * - * This method allows for signed serialization, which prevents the execution of - * untrusted code. To enable this feature, use `Thread::bindSerSecurity()` - * with a long, secret, and unique string before starting any threads. - * - * Example: - * ``` - * php - * Thread::bindSerSecurity('your-super-secret-key-here'); - * $thread = new Thread(new MyTask()); - * $thread->start(); - * ``` - * - * @return DefaultSecurityProvider|null A configured security provider if set via bindSerSecurity(), - * otherwise null. + * Requests graceful termination via SIGTERM. */ - public static function getSerSecurity(): ?DefaultSecurityProvider + public function terminate(): bool { - if (self::$serSecurityKey !== null) { - return new DefaultSecurityProvider( - secret: (string) self::$serSecurityKey - ); - } - return null; + return $this->handle?->signal(SIGTERM) ?? false; } /** - * Gets the configured path to the executable runner script. - * - * Returns the path set via `bindRunner()`, or the default path if `bindRunner()` - * has not been called. The default path points to the `runner` script located - * within this library's directory. - * - * @return string The absolute or relative path to the runner script. + * Forcefully terminates the child via SIGKILL. */ - public static function getRunnerScriptPath(): string + public function kill(): bool { - if (!isset(self::$runnerScriptPath)) { - self::$runnerScriptPath = dirname(__DIR__) . '/wExecutor'; - } - return self::$runnerScriptPath; + return $this->handle?->signal(SIGKILL) ?? false; } /** - * Overrides the default path to the runner script. - * - * This method should be called once at the beginning of your application's bootstrap - * process if you need to use a custom runner script or if the default path is incorrect - * for your project structure. - * - * @param string $runnerScriptPath The absolute path to the custom runner script. + * Gets the namespace of the process. */ - public static function bindRunner(string $runnerScriptPath): void + public function getNamespace(): string { - self::$runnerScriptPath = $runnerScriptPath; + return $this->namespace; } /** - * Sets the secret key for secure closure serialization via Opis/Closure. - * - * This method should be called once at the beginning of your application's bootstrap - * process when using anonymous classes or closures as Runnable tasks. - * - * @param string $serSecurityKey A long, secret, and unique string used to sign serialized closures. + * Gets the name of the task. */ - public static function bindSerSecurity(string $serSecurityKey): void + public function getName(): string { - self::$serSecurityKey = $serSecurityKey; + return $this->name; } /** - * Sets a custom path to the PHP CLI binary. - * - * When running under PHP-FPM, CGI, or other web SAPIs, PHP_BINARY returns the path - * to the web handler (e.g., /usr/sbin/php-fpm83), not the CLI binary needed - * for spawning background processes. Use this method to explicitly specify - * the correct PHP CLI executable path. - * - * @param string $binaryPath The absolute path to the PHP CLI binary (e.g., '/usr/bin/php'). + * Gets the optional tag of the process instance. */ - public static function bindBinaryPath(string $binaryPath): void + public function getTag(): ?string { - self::$binaryPath = $binaryPath; + return $this->tag; } - /** - * Gets the path to the PHP CLI binary. - * - * When running under PHP-FPM, CGI, or other web SAPIs, PHP_BINARY returns the path - * to the web handler (e.g., /usr/sbin/php-fpm83), not the CLI binary needed - * for spawning background processes. This method detects such cases and attempts - * to find the correct PHP CLI executable. - * - * @return string The path to the PHP CLI binary. - */ - private static function getPhpBinaryPath(): string + private function serialize(Engine $engine): string { - if (self::$binaryPath !== null) { - return self::$binaryPath; - } - - $binary = PHP_BINARY; - // If running in CLI, use PHP_BINARY directly - if (PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server') { - return $binary ?: 'php'; - } - - return 'php'; + // opis/closure is a hard dependency; a matching signed/unsigned format is + // produced here and verified by the child (see AdaptiveRunner). + return \Opis\Closure\serialize($this->runnable, $engine->security()); } } diff --git a/src/ThreadException.php b/src/ThreadException.php index f2e8be2..d7e0eac 100644 --- a/src/ThreadException.php +++ b/src/ThreadException.php @@ -1,5 +1,7 @@ assertInstanceOf(PipeTransport::class, $e->transport()); + $this->assertInstanceOf(CliLauncher::class, $e->launcher()); + $this->assertStringEndsWith('wRunner', $e->runnerPath()); + $this->assertNull($e->security()); + } + + public function testAdaptiveHonorsOverrides(): void + { + $e = new AdaptiveEngine(secret: 's3cr3t', transport: new TempFileTransport(), binaryPath: '/usr/bin/php'); + $this->assertInstanceOf(TempFileTransport::class, $e->transport()); + $this->assertSame('/usr/bin/php', $e->binaryPath()); + $this->assertNotNull($e->security()); + } + + public function testManualIsCleanSlate(): void + { + $this->expectException(ThreadException::class); + (new ManualEngine())->transport(); + } + + public function testManualWithersAreImmutable(): void + { + $base = new ManualEngine(); + $configured = $base->withTransport(new PipeTransport())->withBinaryPath('php')->withRunnerPath('wRunner'); + $this->assertInstanceOf(PipeTransport::class, $configured->transport()); + $this->assertSame('php', $configured->binaryPath()); + // Original stays clean: + $this->expectException(ThreadException::class); + $base->transport(); + } + + public function testAdaptiveHonorsInjectedLauncher(): void + { + $custom = $this->stubLauncher(); + $e = new AdaptiveEngine(launcher: $custom); + $this->assertSame($custom, $e->launcher()); + } + + public function testManualWithLauncherIsUsedWithoutOtherConfig(): void + { + $custom = $this->stubLauncher(); + // No transport/binaryPath/runnerPath set — an injected launcher must be + // returned as-is without trying to build a CliLauncher (which would throw). + $engine = (new ManualEngine())->withLauncher($custom); + $this->assertSame($custom, $engine->launcher()); + } + + private function stubLauncher(): Launcher + { + return new class implements Launcher { + public function launch(LaunchSpec $spec): ProcessHandle + { + throw new \LogicException('unused'); + } + }; + } +} diff --git a/tests/Base/Launch/CliLauncherTest.php b/tests/Base/Launch/CliLauncherTest.php new file mode 100644 index 0000000..81955e5 --- /dev/null +++ b/tests/Base/Launch/CliLauncherTest.php @@ -0,0 +1,49 @@ +stub = sys_get_temp_dir() . '/wt-stub-' . uniqid() . '.php'; + file_put_contents($this->stub, <<<'PHP' +stub); + } + + public function testLaunchRunsRunnerWithPipedPayload(): void + { + $outFile = sys_get_temp_dir() . '/wt-cl-' . uniqid() . '.txt'; + $launcher = new CliLauncher(PHP_BINARY, $this->stub, new PipeTransport()); + $spec = new LaunchSpec(payload: 'HELLO', name: 'JobX', arguments: ['out' => $outFile]); + + $handle = $launcher->launch($spec); + $this->assertGreaterThan(0, $handle->getPid()); + $this->assertSame(0, $handle->join()); + + $this->assertSame('HELLO|JobX', file_get_contents($outFile)); + unlink($outFile); + } +} diff --git a/tests/Base/Launch/ProcessHandleTest.php b/tests/Base/Launch/ProcessHandleTest.php new file mode 100644 index 0000000..3692f1b --- /dev/null +++ b/tests/Base/Launch/ProcessHandleTest.php @@ -0,0 +1,99 @@ + ['pipe', 'r'], 1 => ['file', '/dev/null', 'a'], 2 => ['file', '/dev/null', 'a']]; + $pipes = []; + $proc = proc_open($cmd, $desc, $pipes); + $status = proc_get_status($proc); + return new ProcessHandle($proc, $pipes, $status['pid'], new PipeTransport(), new StagedPayload(['pipe', 'r'])); + } + + public function testJoinReturnsExitCode(): void + { + $h = $this->handleFor('exit 3'); + $this->assertSame(3, $h->join()); + $this->assertSame(3, $h->getExitCode()); + } + + public function testReapIsFalseWhileRunningThenTrue(): void + { + $h = $this->handleFor('sleep 30'); + $this->assertFalse($h->reap()); + $this->assertTrue($h->isAlive()); + $h->signal(SIGKILL); + $h->join(); + $this->assertFalse($h->isAlive()); + } + + public function testReapCollectsZombie(): void + { + $h = $this->handleFor('true'); + $pid = $h->getPid(); + while ($h->isAlive()) { + usleep(20_000); + } + $h->reap(); + $state = trim((string) shell_exec('ps -o state= -p ' . $pid . ' 2>/dev/null')); + $this->assertStringStartsNotWith('Z', $state); + } + + public function testReapDoesNotBlockOnLiveProcess(): void + { + $h = $this->handleFor('sleep 5'); + + $t0 = microtime(true); + $reaped = $h->reap(); + $elapsed = microtime(true) - $t0; + + $this->assertFalse($reaped, 'reap() must not collect a still-running process'); + $this->assertLessThan(0.5, $elapsed, 'reap() must be non-blocking on a live process'); + + $h->signal(SIGKILL); + $h->join(); + } + + public function testDetachIsNonBlockingAndLeavesProcessRunning(): void + { + $h = $this->handleFor('sleep 5'); + $pid = $h->getPid(); + + $t0 = microtime(true); + $h->detach(); + $elapsed = microtime(true) - $t0; + + // A proc_close() regression here would block until the child exits (~5s). + $this->assertLessThan(0.5, $elapsed, 'detach() must be non-blocking on a live process'); + $this->assertTrue(posix_kill($pid, 0), 'detach() must leave the worker running, not kill it'); + + // Clean up the abandoned child ourselves. + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + } + + public function testDestructorDoesNotBlockOnLiveProcess(): void + { + $h = $this->handleFor('sleep 5'); + $pid = $h->getPid(); + + $t0 = microtime(true); + unset($h); + gc_collect_cycles(); + $elapsed = microtime(true) - $t0; + + // Dropping a Thread whose child is still running must never hang the parent. + $this->assertLessThan(0.5, $elapsed, 'destructor must not block on a live child'); + + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + } +} diff --git a/tests/Base/LaunchSpecTest.php b/tests/Base/LaunchSpecTest.php new file mode 100644 index 0000000..e8d0ca1 --- /dev/null +++ b/tests/Base/LaunchSpecTest.php @@ -0,0 +1,34 @@ +assertSame('P', $spec->payload); + $this->assertSame('', $spec->namespace); + $this->assertSame('anonymous', $spec->name); + $this->assertNull($spec->tag); + $this->assertSame([], $spec->arguments); + $this->assertFalse($spec->debug); + $this->assertSame('/dev/null', $spec->output); + $this->assertFalse($spec->detached); + } + + public function testAllFields(): void + { + $spec = new LaunchSpec('P', 'ns', 'Job', 't1', ['a' => 'b'], true, null, true); + $this->assertSame('ns', $spec->namespace); + $this->assertSame('Job', $spec->name); + $this->assertSame('t1', $spec->tag); + $this->assertSame(['a' => 'b'], $spec->arguments); + $this->assertTrue($spec->debug); + $this->assertNull($spec->output); + $this->assertTrue($spec->detached); + } +} diff --git a/tests/Base/Payload/PipeTransportTest.php b/tests/Base/Payload/PipeTransportTest.php new file mode 100644 index 0000000..c87e6de --- /dev/null +++ b/tests/Base/Payload/PipeTransportTest.php @@ -0,0 +1,17 @@ +stage('SERIALIZED'); + $this->assertSame(['pipe', 'r'], $staged->stdinSpec); + $this->assertSame('SERIALIZED', $staged->pipePayload); + $this->assertSame([], $staged->cliArgs); + } +} diff --git a/tests/Base/Payload/ShmTransportTest.php b/tests/Base/Payload/ShmTransportTest.php new file mode 100644 index 0000000..8e8d925 --- /dev/null +++ b/tests/Base/Payload/ShmTransportTest.php @@ -0,0 +1,34 @@ +markTestSkipped('ext-shmop not available.'); + } + } + + public function testRoundTrip(): void + { + $t = new ShmTransport(); + $staged = $t->stage('SHM-PAYLOAD'); + + $this->assertSame(['file', '/dev/null', 'r'], $staged->stdinSpec); + $this->assertMatchesRegularExpression('/^--shmkey=\d+$/', $staged->cliArgs[0]); + $key = $staged->ref; + + // child side: receive reads and deletes the segment + $got = $t->receive(['shmkey' => (string) $key]); + $this->assertSame('SHM-PAYLOAD', $got); + + // segment gone → cleanup is a safe no-op + $t->cleanup($staged); + $this->assertTrue(true); + } +} diff --git a/tests/Base/Payload/StagedPayloadTest.php b/tests/Base/Payload/StagedPayloadTest.php new file mode 100644 index 0000000..5a0833b --- /dev/null +++ b/tests/Base/Payload/StagedPayloadTest.php @@ -0,0 +1,28 @@ +assertSame(['pipe', 'r'], $s->stdinSpec); + $this->assertSame(['--shmkey=1'], $s->cliArgs); + $this->assertSame('PL', $s->pipePayload); + $this->assertSame('/tmp/x', $s->unlinkAfterOpen); + $this->assertSame(42, $s->ref); + } + + public function testDefaults(): void + { + $s = new StagedPayload(['file', '/dev/null', 'r']); + $this->assertSame([], $s->cliArgs); + $this->assertNull($s->pipePayload); + $this->assertNull($s->unlinkAfterOpen); + $this->assertNull($s->ref); + } +} diff --git a/tests/Base/Payload/TempFileTransportTest.php b/tests/Base/Payload/TempFileTransportTest.php new file mode 100644 index 0000000..14a3862 --- /dev/null +++ b/tests/Base/Payload/TempFileTransportTest.php @@ -0,0 +1,25 @@ +stage('PAYLOAD-DATA'); + + $this->assertSame('file', $staged->stdinSpec[0]); + $path = $staged->stdinSpec[1]; + $this->assertSame('r', $staged->stdinSpec[2]); + $this->assertSame($path, $staged->unlinkAfterOpen); + $this->assertFileExists($path); + $this->assertSame('PAYLOAD-DATA', file_get_contents($path)); + + $t->cleanup($staged); + $this->assertFileDoesNotExist($path); + } +} diff --git a/tests/Base/Runner/AdaptiveRunnerTest.php b/tests/Base/Runner/AdaptiveRunnerTest.php new file mode 100644 index 0000000..a8a1594 --- /dev/null +++ b/tests/Base/Runner/AdaptiveRunnerTest.php @@ -0,0 +1,50 @@ +markTestSkipped('ext-shmop not available.'); + } + $outFile = sys_get_temp_dir() . '/wt-runner-' . uniqid() . '.txt'; + $runnable = new PayloadProbeTask($outFile); + + // Stage the payload into shm exactly as the launcher would (unsigned here; + // the runner is constructed with no security provider to match). + $shm = new ShmTransport(); + $staged = $shm->stage(\Opis\Closure\serialize($runnable)); + $key = $staged->ref; + + $code = (new AdaptiveRunner())->execute(['shmkey' => (string) $key]); + + $this->assertSame(0, $code); + $this->assertSame('ran:none', file_get_contents($outFile)); + unlink($outFile); + } + + public function testReturnsOneOnInvalidPayload(): void + { + if (!extension_loaded('shmop')) { + $this->markTestSkipped('ext-shmop not available.'); + } + $shm = new ShmTransport(); + $staged = $shm->stage(serialize(['not', 'a', 'runnable'])); + $err = fopen('php://memory', 'w+'); + + $code = (new AdaptiveRunner(null, $err)) + ->execute(['shmkey' => (string) $staged->ref]); + + $this->assertSame(1, $code); + rewind($err); + $this->assertStringContainsString('not a valid Runnable', (string) stream_get_contents($err)); + fclose($err); + } +} diff --git a/tests/Container/ChildProcessProbe.php b/tests/Container/ChildProcessProbe.php new file mode 100644 index 0000000..717db3a --- /dev/null +++ b/tests/Container/ChildProcessProbe.php @@ -0,0 +1,34 @@ +/dev/null'); + $count = 0; + foreach (explode("\n", trim($out)) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = preg_split('/\s+/', $line) ?: []; + if (count($parts) < 2) { + continue; + } + [$ppid, $state] = $parts; + if ((int) $ppid === $self && str_starts_with($state, 'Z')) { + $count++; + } + } + return $count; + } +} diff --git a/tests/Container/Leak/FpmScenarioTest.php b/tests/Container/Leak/FpmScenarioTest.php new file mode 100644 index 0000000..ba1372e --- /dev/null +++ b/tests/Container/Leak/FpmScenarioTest.php @@ -0,0 +1,46 @@ +markTestSkipped('ext-posix not available.'); + } + + // Simulate many "requests", each dispatching a detached background task. + for ($i = 0; $i < 25; $i++) { + $thread = new Thread(new SleepTask(0)); + $thread->start(detached: true); + $thread->join(); // reap only the ephemeral launcher + unset($thread); + } + + usleep(400_000); + $this->assertSame( + 0, + $this->zombieChildCount(), + 'detached workers must reparent to init, never lingering as our zombies', + ); + } +} diff --git a/tests/Container/Leak/LeakCliTest.php b/tests/Container/Leak/LeakCliTest.php new file mode 100644 index 0000000..9247909 --- /dev/null +++ b/tests/Container/Leak/LeakCliTest.php @@ -0,0 +1,92 @@ +start(); + $thread->join(); + } + usleep(200_000); + $this->assertSame(0, $this->zombieChildCount()); + } + + public function testFileDescriptorsDoNotLeak(): void + { + if (!is_dir('/proc/self/fd')) { + $this->markTestSkipped('requires /proc (Linux container)'); + } + + // Warm up (fully reaped), then measure a stable baseline. + for ($i = 0; $i < 5; $i++) { + $warm = new Thread(new SleepTask(0)); + $warm->start(); + $warm->join(); + } + gc_collect_cycles(); + $baseline = count((array) glob('/proc/self/fd/*')); + + for ($i = 0; $i < 30; $i++) { + $thread = new Thread(new SleepTask(0)); + $thread->start(); + $thread->join(); + unset($thread); + } + gc_collect_cycles(); + $after = count((array) glob('/proc/self/fd/*')); + + $this->assertLessThanOrEqual( + $baseline + 3, + $after, + "file descriptors leaked: baseline={$baseline} after={$after}", + ); + } + + public function testMemoryDoesNotGrow(): void + { + for ($i = 0; $i < 5; $i++) { + $thread = new Thread(new SleepTask(0)); + $thread->start(); + $thread->join(); + } + gc_collect_cycles(); + $baseline = memory_get_usage(true); + + for ($i = 0; $i < 40; $i++) { + $thread = new Thread(new SleepTask(0)); + $thread->start(); + $thread->join(); + unset($thread); + } + gc_collect_cycles(); + $after = memory_get_usage(true); + + // Allow one allocator page-bucket of slack; the point is to catch + // unbounded growth, not to assert byte-exact stability. + $this->assertLessThanOrEqual( + $baseline + 1_048_576, + $after, + "memory grew: baseline={$baseline} after={$after}", + ); + } +} diff --git a/tests/Container/Leak/SecurityTest.php b/tests/Container/Leak/SecurityTest.php new file mode 100644 index 0000000..2f5102c --- /dev/null +++ b/tests/Container/Leak/SecurityTest.php @@ -0,0 +1,75 @@ +markTestSkipped('requires /proc (Linux container)'); + } + + $marker = 'WT_MARKER_' . bin2hex(random_bytes(6)); + $thread = new Thread(new MarkedSleepTask($marker, 3)); + $pid = $thread->start(); + + $cmdline = str_replace("\0", ' ', (string) @file_get_contents("/proc/{$pid}/cmdline")); + + $thread->kill(); + $thread->join(); + + $this->assertStringNotContainsString( + $marker, + $cmdline, + 'serialized payload must travel via pipe/file/shm, never argv', + ); + } + + public function testUnsignedPayloadRejectedWhenSecretConfigured(): void + { + if (!extension_loaded('shmop')) { + $this->markTestSkipped('ext-shmop not available.'); + } + + $engine = (new ManualEngine()) + ->withTransport(new ShmTransport()) + ->withBinaryPath('php') + ->withRunnerPath('wRunner') + ->withSecurity('the-secret'); + + // Forge an UNSIGNED payload (serialized without the security provider). + $shm = new ShmTransport(); + $staged = $shm->stage(\Opis\Closure\serialize(new MarkedSleepTask('x', 0))); + + $err = fopen('php://memory', 'w+'); + $code = (new AdaptiveRunner($engine->security(), $err))->execute(['shmkey' => (string) $staged->ref]); + + $this->assertSame(1, $code, 'unsigned payload must be rejected under a configured secret'); + rewind($err); + $this->assertStringContainsString('deserialize', (string) stream_get_contents($err)); + fclose($err); + } +} diff --git a/tests/Container/LeanWorker.php b/tests/Container/LeanWorker.php new file mode 100644 index 0000000..b0fb896 --- /dev/null +++ b/tests/Container/LeanWorker.php @@ -0,0 +1,30 @@ +withTransport(new PipeTransport()) + ->withBinaryPath($wrapper) + ->withRunnerPath(dirname(__DIR__, 2) . '/wRunner'); + } +} diff --git a/tests/Container/Load/StressTest.php b/tests/Container/Load/StressTest.php new file mode 100644 index 0000000..f3f3db7 --- /dev/null +++ b/tests/Container/Load/StressTest.php @@ -0,0 +1,105 @@ + */ + public static function loadProvider(): array + { + return [ + '40 default' => [40, 40, false], + '100 default' => [100, 100, false], + '300 default' => [300, 50, false], + '40 lean' => [40, 40, true], + '100 lean' => [100, 100, true], + '300 lean' => [300, 50, true], + ]; + } + + private function fdCount(): string + { + return is_dir('/proc/self/fd') ? (string) count((array) glob('/proc/self/fd/*')) : 'n/a'; + } + + #[DataProvider('loadProvider')] + public function testConcurrencyScaling(int $total, int $maxConcurrent, bool $lean): void + { + Thread::bindEngine($lean ? $this->leanEngine() : new AdaptiveEngine()); + $worker = $lean ? 'lean' : 'default'; + $fdBefore = $this->fdCount(); + $startedAt = microtime(true); + + /** @var array $inflight */ + $inflight = []; + $launched = 0; + $completed = 0; + + while ($launched < $total || $inflight !== []) { + while ($launched < $total && count($inflight) < $maxConcurrent) { + $out = sys_get_temp_dir() . '/wt-stress-' . uniqid('', true) . '.txt'; + $thread = new Thread(new BatchSumTask(1000, $out)); + $thread->start(); + $inflight[$launched] = ['thread' => $thread, 'out' => $out]; + $launched++; + } + + foreach ($inflight as $key => $job) { + if ($job['thread']->reap()) { + $this->assertSame(0, $job['thread']->getExitCode(), 'worker must exit cleanly'); + $this->assertSame('500500', (string) file_get_contents($job['out']), 'worker result must be correct'); + @unlink($job['out']); + $completed++; + unset($inflight[$key]); + } + } + + usleep(5_000); + } + + $elapsed = microtime(true) - $startedAt; + $zombies = $this->zombieChildCount(); + + fwrite(STDOUT, sprintf( + "\n stress[%-7s]: total=%d, max_concurrent=%d -> %.2fs (%.0f proc/s); zombies=%d; fds %s -> %s\n", + $worker, + $total, + $maxConcurrent, + $elapsed, + $elapsed > 0 ? $total / $elapsed : 0, + $zombies, + $fdBefore, + $this->fdCount(), + )); + + $this->assertSame($total, $completed, 'every worker must complete'); + $this->assertSame(0, $zombies, 'no zombies after the stress run'); + } +} diff --git a/tests/Container/Metrics/MemoryFootprintTest.php b/tests/Container/Metrics/MemoryFootprintTest.php new file mode 100644 index 0000000..6c68b22 --- /dev/null +++ b/tests/Container/Metrics/MemoryFootprintTest.php @@ -0,0 +1,66 @@ +start(); + $thread->join(); + $rssKb = (int) trim((string) @file_get_contents($out)); + @unlink($out); + return round($rssKb / 1024, 1); + } + + public function testReportWorkerMemoryFootprint(): void + { + $payloads = [ + 'empty' => '', + '1 MB' => str_repeat('x', 1024 * 1024), + '8 MB' => str_repeat('x', 8 * 1024 * 1024), + ]; + $engines = [ + 'default build' => fn(): Engine => new AdaptiveEngine(), + 'lean (php -n)' => fn(): Engine => $this->leanEngine(), + ]; + + fwrite(STDOUT, "\n --- winter-thread worker RSS ---\n"); + foreach ($engines as $engineLabel => $factory) { + foreach ($payloads as $payloadLabel => $blob) { + $mb = $this->workerRssMb($factory(), $blob); + fwrite(STDOUT, sprintf(" %-14s %-5s -> %5.1f MB\n", $engineLabel, $payloadLabel, $mb)); + $this->assertGreaterThan(0.0, $mb, 'worker RSS must be measurable'); + } + } + fwrite(STDOUT, " --------------------------------\n"); + } +} diff --git a/tests/Container/Nested/NestedThreadTest.php b/tests/Container/Nested/NestedThreadTest.php new file mode 100644 index 0000000..c5a078f --- /dev/null +++ b/tests/Container/Nested/NestedThreadTest.php @@ -0,0 +1,36 @@ +assertGreaterThan(0, $thread->start()); + $this->assertSame(0, $thread->join()); + + $this->assertSame('level2->level1->leaf', (string) file_get_contents($out)); + @unlink($out); + + $this->assertSame(0, $this->zombieChildCount()); + } +} diff --git a/tests/Container/Payload/LargePayloadTest.php b/tests/Container/Payload/LargePayloadTest.php new file mode 100644 index 0000000..3695d04 --- /dev/null +++ b/tests/Container/Payload/LargePayloadTest.php @@ -0,0 +1,63 @@ + */ + public static function transportProvider(): array + { + return [ + 'pipe' => [PipeTransport::class, false], + 'tempfile' => [TempFileTransport::class, false], + 'shm' => [ShmTransport::class, true], + ]; + } + + #[DataProvider('transportProvider')] + public function testLargePayloadDeliveredIntact(string $transportClass, bool $needsShmop): void + { + if ($needsShmop && !extension_loaded('shmop')) { + $this->markTestSkipped('ext-shmop not available.'); + } + Thread::bindEngine(new AdaptiveEngine(transport: new $transportClass())); + + // ~1.4 MB — well beyond a pipe's ~64 KB buffer. + $data = str_repeat('winter-thread-payload-', 65536); + $out = sys_get_temp_dir() . '/wt-large-' . uniqid('', true) . '.txt'; + + $thread = new Thread(new LargePayloadTask($data, $out)); + $this->assertGreaterThan(0, $thread->start()); + $this->assertSame(0, $thread->join(), 'a large payload must neither deadlock nor fail'); + + $this->assertSame( + strlen($data) . ':' . md5($data), + (string) file_get_contents($out), + 'the payload must arrive byte-intact', + ); + @unlink($out); + } +} diff --git a/tests/Container/Swoole/SwooleScenariosTest.php b/tests/Container/Swoole/SwooleScenariosTest.php new file mode 100644 index 0000000..d9b4d86 --- /dev/null +++ b/tests/Container/Swoole/SwooleScenariosTest.php @@ -0,0 +1,78 @@ +markTestSkipped('ext-swoole not available.'); + } + } + + protected function tearDown(): void + { + if (class_exists('\Swoole\Runtime')) { + \Swoole\Runtime::enableCoroutine(0); + } + Thread::bindEngine(new AdaptiveEngine()); + } + + private function spawnProbe(): ?string + { + $out = sys_get_temp_dir() . '/wt-sw-' . uniqid('', true) . '.txt'; + Thread::bindEngine(new AdaptiveEngine()); + $thread = new Thread(new PayloadProbeTask($out)); + $thread->start(); + $thread->join(); + $result = is_file($out) ? (string) file_get_contents($out) : null; + @unlink($out); + return $result; + } + + public function testInsideCoroutineWithHooks(): void + { + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + $result = null; + try { + \Swoole\Coroutine\run(function () use (&$result): void { + $result = $this->spawnProbe(); + }); + } finally { + \Swoole\Runtime::enableCoroutine(0); + } + + $this->assertSame('ran:none', $result, 'payload intact inside a hooked coroutine'); + $this->assertSame(0, $this->zombieChildCount()); + } + + public function testNoHooksNoCoroutine(): void + { + // Swoole loaded but dormant: the plain pipe transport must work normally. + \Swoole\Runtime::enableCoroutine(0); + $result = $this->spawnProbe(); + + $this->assertSame('ran:none', $result, 'payload intact with swoole loaded but no hooks'); + $this->assertSame(0, $this->zombieChildCount()); + } +} diff --git a/tests/Container/Timing/OverheadTest.php b/tests/Container/Timing/OverheadTest.php new file mode 100644 index 0000000..efcaab4 --- /dev/null +++ b/tests/Container/Timing/OverheadTest.php @@ -0,0 +1,106 @@ + */ + public static function transportProvider(): array + { + return [ + 'pipe' => [PipeTransport::class, false], + 'tempfile' => [TempFileTransport::class, false], + 'shm' => [ShmTransport::class, true], + ]; + } + + /** @param array $samples */ + private function median(array $samples): float + { + sort($samples); + $n = count($samples); + $mid = intdiv($n, 2); + return $n % 2 === 1 ? $samples[$mid] : ($samples[$mid - 1] + $samples[$mid]) / 2; + } + + #[DataProvider('transportProvider')] + public function testAttachedRoundTripLatency(string $transportClass, bool $needsShmop): void + { + if ($needsShmop && !extension_loaded('shmop')) { + $this->markTestSkipped('ext-shmop not available.'); + } + Thread::bindEngine(new AdaptiveEngine(transport: new $transportClass())); + + $samples = []; + for ($i = 0; $i < self::ITERATIONS; $i++) { + $t0 = microtime(true); + $thread = new Thread(new SleepTask(0)); + $thread->start(); + $thread->join(); + $samples[] = microtime(true) - $t0; + } + + $median = $this->median($samples); + $this->assertLessThan( + self::ATTACHED_CEILING_SECONDS, + $median, + sprintf('%s attached median %.3fs exceeds ceiling', $transportClass, $median), + ); + } + + public function testDetachedStartDoesNotBlockOnTheTask(): void + { + if (!function_exists('posix_setsid')) { + $this->markTestSkipped('ext-posix not available.'); + } + Thread::bindEngine(new AdaptiveEngine()); + + // The worker sleeps 2s; a correct detached start returns in ~spawn time + // (well under 1.5s). A blocking start would land around 2s+ and fail. + $samples = []; + for ($i = 0; $i < self::ITERATIONS; $i++) { + $t0 = microtime(true); + $thread = new Thread(new SleepTask(2)); + $thread->start(detached: true); + $samples[] = microtime(true) - $t0; + $thread->join(); // reap the ephemeral launcher only + unset($thread); + } + + $median = $this->median($samples); + $this->assertLessThan( + 1.5, + $median, + sprintf('detached start median %.3fs — start appears to block on the task', $median), + ); + } +} diff --git a/tests/Container/Workload/BattleRunTest.php b/tests/Container/Workload/BattleRunTest.php new file mode 100644 index 0000000..2e23f80 --- /dev/null +++ b/tests/Container/Workload/BattleRunTest.php @@ -0,0 +1,101 @@ + $jobs */ + $jobs = []; + $cleanupDirs = []; + + // CPU-bound: iterated hashing. + for ($i = 1; $i <= 4; $i++) { + $seed = "seed-{$i}"; + $rounds = 500 * $i; + $out = $tmp . '/wt-hash-' . uniqid('', true) . '.txt'; + $expected = $seed; + for ($r = 0; $r < $rounds; $r++) { + $expected = hash('sha256', $expected); + } + $jobs[] = [ + 'thread' => new Thread(new HashTask($seed, $rounds, $out)), + 'out' => $out, + 'expected' => $expected, + ]; + } + + // Arithmetic batches. + for ($i = 1; $i <= 4; $i++) { + $n = 1000 * $i; + $out = $tmp . '/wt-sum-' . uniqid('', true) . '.txt'; + $jobs[] = [ + 'thread' => new Thread(new BatchSumTask($n, $out)), + 'out' => $out, + 'expected' => (string) intdiv($n * ($n + 1), 2), + ]; + } + + // IO-bound. + for ($i = 1; $i <= 4; $i++) { + $dir = $tmp . '/wt-io-' . uniqid('', true); + mkdir($dir); + $cleanupDirs[] = $dir; + $count = 40; + $out = $tmp . '/wt-io-out-' . uniqid('', true) . '.txt'; + $jobs[] = [ + 'thread' => new Thread(new FileIoTask($dir, $count, $out)), + 'out' => $out, + 'expected' => (string) ($count * 100), + ]; + } + + // Launch everything in parallel. + foreach ($jobs as $job) { + $job['thread']->start(); + } + + // Join and verify each result. + foreach ($jobs as $job) { + $this->assertSame(0, $job['thread']->join(), 'workload exit code'); + $this->assertSame( + $job['expected'], + (string) file_get_contents($job['out']), + 'workload produced an incorrect result', + ); + @unlink($job['out']); + } + + foreach ($cleanupDirs as $dir) { + @rmdir($dir); + } + + $this->assertSame(0, $this->zombieChildCount(), 'no zombies after the battle run'); + } +} diff --git a/tests/Fixtures/BatchSumTask.php b/tests/Fixtures/BatchSumTask.php new file mode 100644 index 0000000..c83c4a2 --- /dev/null +++ b/tests/Fixtures/BatchSumTask.php @@ -0,0 +1,28 @@ +n; $i++) { + $sum += $i; + } + file_put_contents($this->outFile, (string) $sum); + } +} diff --git a/tests/Fixtures/FileIoTask.php b/tests/Fixtures/FileIoTask.php new file mode 100644 index 0000000..5be8b5e --- /dev/null +++ b/tests/Fixtures/FileIoTask.php @@ -0,0 +1,37 @@ +count; $i++) { + file_put_contents($this->dir . '/f' . $i . '.txt', str_repeat('x', 100)); + } + + $total = 0; + for ($i = 0; $i < $this->count; $i++) { + $path = $this->dir . '/f' . $i . '.txt'; + $total += strlen((string) file_get_contents($path)); + @unlink($path); + } + + file_put_contents($this->outFile, (string) $total); + } +} diff --git a/tests/Fixtures/HashTask.php b/tests/Fixtures/HashTask.php new file mode 100644 index 0000000..af9d431 --- /dev/null +++ b/tests/Fixtures/HashTask.php @@ -0,0 +1,30 @@ +seed; + for ($i = 0; $i < $this->rounds; $i++) { + $h = hash('sha256', $h); + } + file_put_contents($this->outFile, $h); + } +} diff --git a/tests/Fixtures/IdleTask.php b/tests/Fixtures/IdleTask.php new file mode 100644 index 0000000..d35dbf0 --- /dev/null +++ b/tests/Fixtures/IdleTask.php @@ -0,0 +1,27 @@ +blob; + sleep($this->seconds); + unset($held); + } +} diff --git a/tests/Fixtures/LargePayloadTask.php b/tests/Fixtures/LargePayloadTask.php new file mode 100644 index 0000000..2c463ca --- /dev/null +++ b/tests/Fixtures/LargePayloadTask.php @@ -0,0 +1,25 @@ +outFile, strlen($this->data) . ':' . md5($this->data)); + } +} diff --git a/tests/Fixtures/MarkedSleepTask.php b/tests/Fixtures/MarkedSleepTask.php new file mode 100644 index 0000000..dcb7b6e --- /dev/null +++ b/tests/Fixtures/MarkedSleepTask.php @@ -0,0 +1,29 @@ +/cmdline and + * prove the payload (marker) never leaks into the process arguments. + */ +class MarkedSleepTask implements Runnable +{ + public function __construct( + private string $marker, + private int $seconds = 3, + ) { + } + + public function run(array $args): void + { + // Reference the marker so it cannot be optimized away. + $seen = $this->marker; + sleep($this->seconds); + unset($seen); + } +} diff --git a/tests/Fixtures/MemoryReportTask.php b/tests/Fixtures/MemoryReportTask.php new file mode 100644 index 0000000..c391dfd --- /dev/null +++ b/tests/Fixtures/MemoryReportTask.php @@ -0,0 +1,38 @@ +blob; + file_put_contents($this->outFile, (string) self::selfRssKb()); + unset($held); + } + + public static function selfRssKb(): int + { + $status = @file_get_contents('/proc/self/status'); + if ($status !== false && preg_match('/^VmRSS:\s+(\d+)/m', $status, $m) === 1) { + return (int) $m[1]; + } + // macOS / no procfs: ask ps about our own PID. + return (int) trim((string) shell_exec('ps -o rss= -p ' . getmypid() . ' 2>/dev/null')); + } +} diff --git a/tests/Fixtures/NestingTask.php b/tests/Fixtures/NestingTask.php new file mode 100644 index 0000000..78ead79 --- /dev/null +++ b/tests/Fixtures/NestingTask.php @@ -0,0 +1,40 @@ +depth <= 0) { + file_put_contents($this->outFile, 'leaf'); + return; + } + + $childFile = $this->outFile . '.d' . $this->depth; + $child = new Thread(new NestingTask($this->depth - 1, $childFile)); + $child->start(); + $child->join(); + + $childResult = is_file($childFile) ? (string) file_get_contents($childFile) : 'MISSING'; + @unlink($childFile); + + file_put_contents($this->outFile, 'level' . $this->depth . '->' . $childResult); + } +} diff --git a/tests/Fixtures/PayloadProbeTask.php b/tests/Fixtures/PayloadProbeTask.php new file mode 100644 index 0000000..b899f59 --- /dev/null +++ b/tests/Fixtures/PayloadProbeTask.php @@ -0,0 +1,23 @@ +" to a file, proving the payload round-tripped and run() executed. + * A named fixture (not an anonymous class) so it serializes cleanly. + */ +class PayloadProbeTask implements Runnable +{ + public function __construct(private string $file) + { + } + + public function run(array $args): void + { + file_put_contents($this->file, 'ran:' . ($args['tag'] ?? 'none')); + } +} diff --git a/tests/Fixtures/PidReportTask.php b/tests/Fixtures/PidReportTask.php new file mode 100644 index 0000000..7786fb3 --- /dev/null +++ b/tests/Fixtures/PidReportTask.php @@ -0,0 +1,24 @@ +pidFile, (string) getmypid()); + sleep(2); + } +} diff --git a/tests/Working/DetachedTest.php b/tests/Working/DetachedTest.php new file mode 100644 index 0000000..bbbd414 --- /dev/null +++ b/tests/Working/DetachedTest.php @@ -0,0 +1,45 @@ +markTestSkipped('ext-posix not available.'); + } + + $pidFile = sys_get_temp_dir() . '/wt-detach-' . uniqid() . '.pid'; + + $thread = new Thread(new PidReportTask($pidFile)); + $launcherPid = $thread->start(detached: true); + $thread->join(); // reap the ephemeral launcher process + + $deadline = time() + 5; + while (!is_file($pidFile) && time() < $deadline) { + usleep(20_000); + } + + $this->assertFileExists($pidFile); + $workerPid = (int) file_get_contents($pidFile); + $this->assertGreaterThan(0, $workerPid); + $this->assertNotSame($launcherPid, $workerPid); + + $ppid = trim((string) shell_exec('ps -o ppid= -p ' . $workerPid . ' 2>/dev/null')); + $this->assertSame('1', $ppid, 'detached worker should be reparented to init (pid 1)'); + + @unlink($pidFile); + } +} diff --git a/tests/Working/FailureModesTest.php b/tests/Working/FailureModesTest.php new file mode 100644 index 0000000..6a7d9ac --- /dev/null +++ b/tests/Working/FailureModesTest.php @@ -0,0 +1,113 @@ +start(); + + try { + $thread->start(); + $this->fail('expected ThreadException when starting an already-running thread'); + } catch (ThreadException $e) { + $this->assertStringContainsString('already running', $e->getMessage()); + } finally { + $thread->kill(); + $thread->join(); + } + } + + public function testMissingBinaryIsNotSilentSuccess(): void + { + Thread::bindEngine( + (new ManualEngine()) + ->withTransport(new PipeTransport()) + ->withBinaryPath('/nonexistent/php-xyz') + ->withRunnerPath($this->runnerPath()), + ); + $this->assertNotSilentSuccess(new Thread(new SleepTask(0))); + } + + public function testMissingRunnerIsNotSilentSuccess(): void + { + Thread::bindEngine( + (new ManualEngine()) + ->withTransport(new PipeTransport()) + ->withBinaryPath(PHP_BINARY) + ->withRunnerPath('/nonexistent/wRunner-xyz'), + ); + $this->assertNotSilentSuccess(new Thread(new SleepTask(0))); + } + + public function testRunnableExceptionYieldsNonZeroExit(): void + { + Thread::bindEngine(new AdaptiveEngine()); + $thread = new Thread(new FailTask()); + $thread->start(); + $this->assertNotSame(0, $thread->join(), 'a throwing Runnable must exit non-zero'); + } + + public function testOperationsAfterDetachAreInert(): void + { + Thread::bindEngine(new AdaptiveEngine()); + $thread = new Thread(new SleepTask(0)); + $pid = $thread->start(); + $thread->detach(); + + $this->assertFalse($thread->isAlive()); + $this->assertTrue($thread->reap()); + $this->assertFalse($thread->kill()); + $this->assertFalse($thread->pause()); + $this->assertSame(-1, $thread->join()); + + // Reap the abandoned child ourselves so the test leaves nothing behind. + if ($pid !== null && function_exists('pcntl_waitpid')) { + @pcntl_waitpid($pid, $status); + } + } + + /** + * Asserts that a doomed launch never reports success: it either throws a + * ThreadException at start, or the child exits with a non-zero code. + */ + private function assertNotSilentSuccess(Thread $thread): void + { + try { + $thread->start(); + } catch (ThreadException) { + $this->addToAssertionCount(1); + return; + } + $exit = $thread->join(); + $this->assertNotSame(0, $exit, 'a broken launch must not report exit code 0'); + } +} diff --git a/tests/Working/PoolLoopTest.php b/tests/Working/PoolLoopTest.php new file mode 100644 index 0000000..7450e5e --- /dev/null +++ b/tests/Working/PoolLoopTest.php @@ -0,0 +1,50 @@ +launcher(); + + /** @var array $handles */ + $handles = []; + $pids = []; + for ($i = 0; $i < 6; $i++) { + $spec = new LaunchSpec( + payload: \Opis\Closure\serialize(new SleepTask(0)), + name: 'pool-worker', + ); + $handle = $launcher->launch($spec); + $handles[$i] = $handle; + $pids[$i] = $handle->getPid(); + } + + $deadline = time() + 10; + while ($handles !== [] && time() < $deadline) { + $handles = array_filter($handles, static fn(ProcessHandle $h): bool => !$h->reap()); + usleep(20_000); + } + + $this->assertSame([], $handles, 'every handle was reaped by the loop'); + + foreach ($pids as $pid) { + $state = trim((string) shell_exec('ps -o state= -p ' . $pid . ' 2>/dev/null')); + $this->assertStringStartsNotWith('Z', $state, "pid {$pid} must not be a zombie"); + } + } +} diff --git a/tests/SignalTest.php b/tests/Working/SignalTest.php similarity index 85% rename from tests/SignalTest.php rename to tests/Working/SignalTest.php index b272cf4..9329af9 100644 --- a/tests/SignalTest.php +++ b/tests/Working/SignalTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\Thread\Tests; +namespace Flytachi\Winter\Thread\Tests\Working; use Flytachi\Winter\Thread\Signal; use Flytachi\Winter\Thread\Thread; @@ -11,6 +11,16 @@ class SignalTest extends TestCase { + /** Polls until the process dies (or a timeout), then asserts it is gone. */ + private function assertTerminatesWithin(Thread $thread, float $seconds = 3.0): void + { + $deadline = microtime(true) + $seconds; + while ($thread->isAlive() && microtime(true) < $deadline) { + usleep(20_000); + } + $this->assertFalse($thread->isAlive()); + } + // --- isProcessRunning --- public function testIsProcessRunningForCurrentProcess(): void @@ -30,8 +40,7 @@ public function testInterruptSendsSignal(): void $thread = new Thread(new SleepTask(30)); $pid = $thread->start(); $this->assertTrue(Signal::interrupt($pid)); - usleep(300_000); - $this->assertFalse($thread->isAlive()); + $this->assertTerminatesWithin($thread); } public function testInterruptReturnsFalseForNonExistentPid(): void @@ -46,8 +55,7 @@ public function testTerminationSendsSignal(): void $thread = new Thread(new SleepTask(30)); $pid = $thread->start(); $this->assertTrue(Signal::termination($pid)); - usleep(300_000); - $this->assertFalse($thread->isAlive()); + $this->assertTerminatesWithin($thread); } public function testTerminationReturnsFalseForNonExistentPid(): void @@ -62,8 +70,7 @@ public function testKillSendsSignal(): void $thread = new Thread(new SleepTask(30)); $pid = $thread->start(); $this->assertTrue(Signal::kill($pid)); - usleep(300_000); - $this->assertFalse($thread->isAlive()); + $this->assertTerminatesWithin($thread); } public function testKillReturnsFalseForNonExistentPid(): void @@ -78,8 +85,7 @@ public function testCloseSendsHupSignal(): void $thread = new Thread(new SleepTask(30)); $pid = $thread->start(); $this->assertTrue(Signal::close($pid)); - usleep(300_000); - $this->assertFalse($thread->isAlive()); + $this->assertTerminatesWithin($thread); } public function testCloseReturnsFalseForNonExistentPid(): void diff --git a/tests/Working/ThreadFacadeTest.php b/tests/Working/ThreadFacadeTest.php new file mode 100644 index 0000000..d93f336 --- /dev/null +++ b/tests/Working/ThreadFacadeTest.php @@ -0,0 +1,49 @@ +assertInstanceOf(AdaptiveEngine::class, Thread::engine()); + } + + public function testBindEngineIsUsed(): void + { + $spy = new AdaptiveEngine(); + Thread::bindEngine($spy); + $this->assertSame($spy, Thread::engine()); + } + + public function testStartJoinRoundTripThroughFacade(): void + { + $thread = new Thread(new SleepTask(0)); + $pid = $thread->start(); + $this->assertGreaterThan(0, $pid); + $this->assertSame(0, $thread->join()); + $this->assertSame(0, $thread->getExitCode()); + } + + public function testFileOutputThroughFacade(): void + { + $log = sys_get_temp_dir() . '/wt-facade-' . uniqid() . '.log'; + (new Thread(new EchoTask('facade-hello')))->start(outputTarget: $log); + usleep(300_000); + $this->assertStringContainsString('facade-hello', (string) file_get_contents($log)); + unlink($log); + } +} diff --git a/tests/ThreadTest.php b/tests/Working/ThreadTest.php similarity index 65% rename from tests/ThreadTest.php rename to tests/Working/ThreadTest.php index af33a57..4c12629 100644 --- a/tests/ThreadTest.php +++ b/tests/Working/ThreadTest.php @@ -2,10 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\Thread\Tests; +namespace Flytachi\Winter\Thread\Tests\Working; +use Flytachi\Winter\Thread\Engine\AdaptiveEngine; +use Flytachi\Winter\Thread\Engine\ManualEngine; +use Flytachi\Winter\Thread\Payload\PipeTransport; +use Flytachi\Winter\Thread\Payload\ShmTransport; +use Flytachi\Winter\Thread\Payload\TempFileTransport; use Flytachi\Winter\Thread\Thread; -use Flytachi\Winter\Thread\ThreadException; use Flytachi\Winter\Thread\Tests\Fixtures\ArgsTask; use Flytachi\Winter\Thread\Tests\Fixtures\EchoTask; use Flytachi\Winter\Thread\Tests\Fixtures\FailTask; @@ -14,17 +18,10 @@ class ThreadTest extends TestCase { - private string $originalRunnerPath; - - protected function setUp(): void - { - $this->originalRunnerPath = Thread::getRunnerScriptPath(); - } - protected function tearDown(): void { - Thread::bindRunner($this->originalRunnerPath); - Thread::bindPayloadMode(Thread::PAYLOAD_PIPE); + // Reset to a fresh default engine so a bindEngine() in any test never leaks. + Thread::bindEngine(new AdaptiveEngine()); } // --- Constructor & Getters --- @@ -35,6 +32,35 @@ public function testConstructorAutoDetectsNameFromClass(): void $this->assertSame('SleepTask', $thread->getName()); } + /** + * Regression: an unsigned engine must not break when the parent environment + * carries a stray WINTER_THREAD_SECRET (the child would otherwise inherit it, + * build a verifier, and reject every unsigned payload). + */ + public function testUnsignedEngineIgnoresAmbientSecret(): void + { + $prev = getenv('WINTER_THREAD_SECRET'); + putenv('WINTER_THREAD_SECRET=ambient-leak'); + try { + $adaptive = new AdaptiveEngine(); + Thread::bindEngine( + (new ManualEngine()) + ->withTransport(new PipeTransport()) + ->withBinaryPath($adaptive->binaryPath()) + ->withRunnerPath($adaptive->runnerPath()) + ); + $thread = new Thread(new SleepTask(0)); + $thread->start(); + $this->assertSame(0, $thread->join(), 'unsigned task must run despite an ambient secret'); + } finally { + if ($prev === false) { + putenv('WINTER_THREAD_SECRET'); + } else { + putenv("WINTER_THREAD_SECRET={$prev}"); + } + } + } + public function testConstructorDefaults(): void { $thread = new Thread(new SleepTask(0)); @@ -169,6 +195,144 @@ public function testJoinTimeoutReturnsNull(): void $thread->kill(); } + // --- reap() / getExitCode() --- + + public function testReapReturnsFalseWhileRunning(): void + { + $thread = new Thread(new SleepTask(30)); + $thread->start(); + $this->assertFalse($thread->reap()); + $this->assertTrue($thread->isAlive()); + $thread->kill(); + $thread->join(); + } + + public function testReapReturnsTrueForNotStarted(): void + { + $thread = new Thread(new SleepTask(0)); + $this->assertTrue($thread->reap()); + } + + public function testReapReturnsTrueAfterProcessFinishes(): void + { + $thread = new Thread(new SleepTask(0)); + $thread->start(); + // Wait for the child to exit without reaping it. + while ($thread->isAlive()) { + usleep(20_000); + } + $this->assertTrue($thread->reap()); + $this->assertFalse($thread->isAlive()); + } + + public function testReapCollectsZombie(): void + { + $thread = new Thread(new SleepTask(0)); + $pid = $thread->start(); + + // Let the child exit. Until reaped it lingers as a zombie (state Z). + while ($thread->isAlive()) { + usleep(20_000); + } + + $thread->reap(); + + // After reaping, the PID must no longer exist as a zombie. + $state = trim((string) shell_exec('ps -o state= -p ' . (int) $pid . ' 2>/dev/null')); + $this->assertStringStartsNotWith('Z', $state); + } + + public function testGetExitCodeIsNullBeforeFinish(): void + { + $thread = new Thread(new SleepTask(0)); + $this->assertNull($thread->getExitCode()); + $thread->start(); + $this->assertNull($thread->getExitCode()); + $thread->join(); + } + + public function testGetExitCodeAfterJoin(): void + { + $thread = new Thread(new SleepTask(0)); + $thread->start(); + $thread->join(); + $this->assertSame(0, $thread->getExitCode()); + } + + public function testGetExitCodeAfterReap(): void + { + $thread = new Thread(new FailTask()); + $thread->start(); + while ($thread->isAlive()) { + usleep(20_000); + } + $thread->reap(); + $this->assertNotSame(0, $thread->getExitCode()); + $this->assertNotNull($thread->getExitCode()); + } + + public function testReapInPoolLoopHarvestsFinished(): void + { + // Mixed pool: some short, some long. reap() must release the short ones + // while the long ones keep running, all without blocking. + $running = [ + new Thread(new SleepTask(0)), + new Thread(new SleepTask(0)), + new Thread(new SleepTask(30)), + ]; + foreach ($running as $t) { + $t->start(); + } + + // Give the short tasks time to finish. + usleep(300_000); + + $running = array_values(array_filter($running, fn(Thread $t) => !$t->reap())); + + $this->assertCount(1, $running); // only the long-running one survives + $running[0]->kill(); + $running[0]->join(); + } + + // --- detach() --- + + public function testDetachStopsTracking(): void + { + $thread = new Thread(new SleepTask(1)); + $thread->start(); + $thread->detach(); + + // After detaching, the handle is gone: tracking methods report inactive. + $this->assertFalse($thread->isAlive()); + $this->assertTrue($thread->reap()); + $this->assertFalse($thread->kill()); + } + + public function testDetachOnNotStartedIsNoop(): void + { + $thread = new Thread(new SleepTask(0)); + $thread->detach(); + $this->assertFalse($thread->isAlive()); + } + + // --- destructor --- + + public function testDestructorReapsFinishedProcessWithoutZombie(): void + { + $thread = new Thread(new SleepTask(0)); + $pid = $thread->start(); + while ($thread->isAlive()) { + usleep(20_000); + } + + // Drop the only reference: the destructor must reap the finished child. + unset($thread); + gc_collect_cycles(); + + $state = trim((string) shell_exec('ps -o state= -p ' . (int) $pid . ' 2>/dev/null')); + $this->assertStringStartsNotWith('Z', $state); + } + // --- isAlive() --- public function testIsAliveReturnsFalseBeforeStart(): void @@ -329,57 +493,35 @@ public function testReadOutputReturnsEmptyWhenDevNull(): void $this->assertSame('', $thread->readError()); } - // --- Payload mode --- + // --- Payload transport (engine-driven) --- - public function testBindPayloadModeWithInvalidModeThrowsException(): void + public function testTempFileEngineStartsAndJoins(): void { - $this->expectException(ThreadException::class); - Thread::bindPayloadMode('invalid_mode'); - } - - public function testBindPayloadModeShmThrowsIfExtensionMissing(): void - { - if (extension_loaded('shmop')) { - $this->markTestSkipped('ext-shmop is loaded; cannot test missing-extension path.'); - } - $this->expectException(ThreadException::class); - Thread::bindPayloadMode(Thread::PAYLOAD_SHM); - } - - public function testShmPayloadModeStartsAndJoins(): void - { - if (!extension_loaded('shmop')) { - $this->markTestSkipped('ext-shmop not available.'); - } - Thread::bindPayloadMode(Thread::PAYLOAD_SHM); + Thread::bindEngine((new AdaptiveEngine(transport: new TempFileTransport()))); $thread = new Thread(new SleepTask(0)); $pid = $thread->start(); $this->assertGreaterThan(0, $pid); $this->assertSame(0, $thread->join()); } - public function testShmPayloadModeIsolatedInLoop(): void + public function testTempFileEngineDeliversCorrectOutput(): void { - if (!extension_loaded('shmop')) { - $this->markTestSkipped('ext-shmop not available.'); - } - Thread::bindPayloadMode(Thread::PAYLOAD_SHM); - $threads = []; - for ($i = 0; $i < 5; $i++) { - $threads[$i] = new Thread(new SleepTask(0)); - $threads[$i]->start(); - } - foreach ($threads as $thread) { - $this->assertSame(0, $thread->join()); - } + Thread::bindEngine((new AdaptiveEngine(transport: new TempFileTransport()))); + $logFile = sys_get_temp_dir() . '/wt-tmpfile-' . uniqid() . '.log'; + $thread = new Thread(new EchoTask('swoole-safe')); + $thread->start(outputTarget: $logFile); + $thread->join(); + + $this->assertStringContainsString('swoole-safe', (string) file_get_contents($logFile)); + unlink($logFile); } - public function testShmPayloadModeDeliversCorrectOutput(): void + public function testShmEngineDeliversCorrectOutput(): void { if (!extension_loaded('shmop')) { $this->markTestSkipped('ext-shmop not available.'); } - Thread::bindPayloadMode(Thread::PAYLOAD_SHM); + Thread::bindEngine((new AdaptiveEngine(transport: new ShmTransport()))); $logFile = sys_get_temp_dir() . '/wt-shm-' . uniqid() . '.log'; $thread = new Thread(new EchoTask('shm-payload')); $thread->start(outputTarget: $logFile); @@ -388,53 +530,4 @@ public function testShmPayloadModeDeliversCorrectOutput(): void $this->assertStringContainsString('shm-payload', (string) file_get_contents($logFile)); unlink($logFile); } - - public function testTempFilePayloadModeStartsAndJoins(): void - { - Thread::bindPayloadMode(Thread::PAYLOAD_TEMP_FILE); - $thread = new Thread(new SleepTask(0)); - $pid = $thread->start(); - $this->assertGreaterThan(0, $pid); - $this->assertSame(0, $thread->join()); - } - - public function testTempFilePayloadModeIsolatedInLoop(): void - { - Thread::bindPayloadMode(Thread::PAYLOAD_TEMP_FILE); - $threads = []; - for ($i = 0; $i < 5; $i++) { - $threads[$i] = new Thread(new SleepTask(0)); - $threads[$i]->start(); - } - foreach ($threads as $thread) { - $this->assertSame(0, $thread->join()); - } - } - - public function testTempFilePayloadModeDeliversCorrectOutput(): void - { - Thread::bindPayloadMode(Thread::PAYLOAD_TEMP_FILE); - $logFile = sys_get_temp_dir() . '/wt-tmpfile-' . uniqid() . '.log'; - $thread = new Thread(new EchoTask('swoole-safe')); - $thread->start(outputTarget: $logFile); - $thread->join(); - - $this->assertStringContainsString('swoole-safe', (string) file_get_contents($logFile)); - unlink($logFile); - } - - // --- Static configuration --- - - public function testBindRunnerChangesScriptPath(): void - { - Thread::bindRunner('/tmp/custom-runner'); - $this->assertSame('/tmp/custom-runner', Thread::getRunnerScriptPath()); - } - - public function testGetRunnerScriptPathReturnsDefaultWhenNotSet(): void - { - $path = Thread::getRunnerScriptPath(); - $this->assertStringEndsWith('wExecutor', $path); - $this->assertFileExists($path); - } } diff --git a/tests/Working/TransportScenariosTest.php b/tests/Working/TransportScenariosTest.php new file mode 100644 index 0000000..3b7e809 --- /dev/null +++ b/tests/Working/TransportScenariosTest.php @@ -0,0 +1,81 @@ + */ + public static function transportProvider(): array + { + return [ + 'pipe' => [PipeTransport::class, false], + 'tempfile' => [TempFileTransport::class, false], + 'shm' => [ShmTransport::class, true], + ]; + } + + private function bind(string $transportClass, bool $needsShmop): void + { + if ($needsShmop && !extension_loaded('shmop')) { + $this->markTestSkipped('ext-shmop not available.'); + } + Thread::bindEngine(new AdaptiveEngine(transport: new $transportClass())); + } + + #[DataProvider('transportProvider')] + public function testStartAndJoin(string $transportClass, bool $needsShmop): void + { + $this->bind($transportClass, $needsShmop); + $thread = new Thread(new SleepTask(0)); + $this->assertGreaterThan(0, $thread->start()); + $this->assertSame(0, $thread->join()); + } + + #[DataProvider('transportProvider')] + public function testFileOutput(string $transportClass, bool $needsShmop): void + { + $this->bind($transportClass, $needsShmop); + $log = sys_get_temp_dir() . '/wt-scen-' . uniqid() . '.log'; + (new Thread(new EchoTask('scenario-out')))->start(outputTarget: $log); + usleep(300_000); + $this->assertStringContainsString('scenario-out', (string) file_get_contents($log)); + unlink($log); + } + + #[DataProvider('transportProvider')] + public function testCustomArguments(string $transportClass, bool $needsShmop): void + { + $this->bind($transportClass, $needsShmop); + $out = sys_get_temp_dir() . '/wt-args-' . uniqid() . '.txt'; + $thread = new Thread(new ArgsTask($out)); + $thread->start(['user' => 'alice', 'count' => '3']); + $thread->join(); + + $content = (string) file_get_contents($out); + $this->assertStringContainsString('user=alice', $content); + $this->assertStringContainsString('count=3', $content); + unlink($out); + } +} diff --git a/tests/Working/WRunnerTest.php b/tests/Working/WRunnerTest.php new file mode 100644 index 0000000..1afbf8b --- /dev/null +++ b/tests/Working/WRunnerTest.php @@ -0,0 +1,42 @@ +assertFileExists($root . '/wRunner'); + $this->assertFileDoesNotExist($root . '/wExecutor'); + + $composer = json_decode((string) file_get_contents($root . '/composer.json'), true); + $this->assertSame(['wRunner'], $composer['bin']); + $this->assertSame('>=8.4', $composer['require']['php']); + } + + public function testWRunnerExecutesPipedRunnable(): void + { + $root = dirname(__DIR__, 2); + $outFile = sys_get_temp_dir() . '/wt-bin-' . uniqid() . '.txt'; + $runnable = new PayloadProbeTask($outFile); + + $desc = [0 => ['pipe', 'r'], 1 => ['file', '/dev/null', 'a'], 2 => ['file', '/dev/null', 'a']]; + $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/wRunner') . ' --name=Test'; + $proc = proc_open($cmd, $desc, $pipes); + fwrite($pipes[0], \Opis\Closure\serialize($runnable)); + fclose($pipes[0]); + while (proc_get_status($proc)['running']) { + usleep(20_000); + } + proc_close($proc); + + $this->assertSame('ran:none', file_get_contents($outFile)); + unlink($outFile); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..976b8ba --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ +/dev/null 2>&1 || true +done + +if [ "${#FAILED[@]}" -ne 0 ]; then + echo "FAILED versions: ${FAILED[*]}" + exit 1 +fi +echo "All requested PHP versions passed." diff --git a/wExecutor b/wExecutor deleted file mode 100644 index aa65c73..0000000 --- a/wExecutor +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env php - {$processName}@$processTag"); - unset($processNamespace, $processTag, $processName); -} - -// 5. Let's complete the task. -try { - - // Custom args - $customArgs = []; - for ($i = 1; $i < count($argv); $i++) { - $arg = $argv[$i]; - if (str_starts_with($arg, '--arg-')) { - $argContent = substr($arg, 6); - if (str_contains($argContent, '=')) { - list($key, $value) = explode('=', $argContent, 2); - $customArgs[$key] = $value; - } else { - $customArgs[$argContent] = true; - } - } - } - - $runnable->run($customArgs); - exit(0); -} catch (\Throwable $e) { - fwrite(STDERR, "Uncaught exception in background process: " . $e->getMessage() . "\n"); - fwrite(STDERR, $e->getTraceAsString() . "\n"); - exit(1); -} diff --git a/wRunner b/wRunner new file mode 100755 index 0000000..76fd8a7 --- /dev/null +++ b/wRunner @@ -0,0 +1,35 @@ +#!/usr/bin/env php +/environ), never argv. Build the verifier from it. +$secret = getenv('WINTER_THREAD_SECRET') ?: null; +$security = $secret !== null ? new DefaultSecurityProvider(secret: $secret) : null; + +exit(new AdaptiveRunner($security)->execute($options));