flytachi/winter-thread

A lightweight process engine for PHP — Java-like control of background tasks as isolated OS processes, without heavy extensions (no swoole/pthreads/parallel).

Maintainers

Package info

github.com/Flytachi/winter-thread

Homepage

Issues

Documentation

pkg:composer/flytachi/winter-thread

Transparency log

Statistics

Installs: 676

Dependents: 1

Suggesters: 0

Stars: 1

v3.1.1 2026-08-17 18:12 UTC

This package is auto-updated.

Last update: 2026-08-17 18:19:20 UTC


README

Tests Latest Version on Packagist PHP Version Require Software License

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's an engine — the foundation you build on. A small, dependable core, not a batteries-included framework: the layer your queues, pools, schedulers and workers sit on top of. You bring the higher-level concurrency; the engine handles the hard, boring parts — spawning, signals, isolation, transports.

A Thread here is a process, not a PHP thread. The name is a deliberate nod to a familiar API — just as Python's multiprocessing.Process mirrors its threading interface. Every Thread is one fully isolated OS process wearing a clean, thread-like face (start(), join(), isAlive()) — so there's no shared state to corrupt and nothing to leak between tasks.

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.

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: Launch background tasks from inside a coroutine safely — the default AdaptiveLauncher routes to a Swoole-native backend (Coroutine\System::exec) where proc_open would corrupt the reactor's fds, with pipe-free payload transports (temp-file, shared-memory). No ext-swoole required; it's used only when present.
  • Zombie-free fire-and-forget: Optional detached mode (fork + setsid) reparents workers to init, so long-lived parents (FPM, daemons) never accumulate zombies.
  • Pluggable backend: Swap the payload transport or the whole spawn strategy through a single Launcher — 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.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

composer require flytachi/winter-thread

Quick Start

<?php

require 'vendor/autoload.php';

use Flytachi\Winter\Thread\Runnable;
use Flytachi\Winter\Thread\Thread;

// 1. Define your task by implementing Runnable.
//    Logic inside run() executes in a separate process.
class VideoProcessingTask implements Runnable {
    public function __construct(private string $videoFile) {}

    public function run(array $args): void {
        $quality = $args['quality'] ?? 'high';
        // output goes to /dev/null by default — use outputTarget for logging
        sleep(5); // simulate encoding
    }
}

// 2. Create a Thread with optional metadata for OS process identification.
$thread = new Thread(
    new VideoProcessingTask('movie.mp4'),
    'Media',          // namespace
    'VideoProcessor', // name
    'job-42'          // tag
);

// 3. Start the thread.
//    Default outputTarget='/dev/null' — safe for fire-and-forget.
//    Pass outputTarget: '/path/to/file.log' to capture output.
//    Pass outputTarget: null ONLY when actively reading via readOutput().
$pid = $thread->start(['quality' => 'hd']);
echo "Processing started (PID: $pid)\n";

// Main script continues immediately.
echo "Doing other work...\n";

// 4. Optionally wait for the task to finish.
$exitCode = $thread->join();
echo "Task finished with exit code: $exitCode\n";

Configuration — the Launcher

Configuration goes through a single Launcher, bound once at bootstrap with Thread::bindLauncher(). When you bind nothing, a self-configuring AdaptiveLauncher is used (AdaptiveLauncher::adaptive()), which routes each launch to the right backend for the current runtime — CliLauncher on CLI / FPM, SwooleLauncher inside a Swoole coroutine.

use Flytachi\Winter\Thread\Launch\CliLauncher;
use Flytachi\Winter\Thread\Payload\TempFileTransport;

// Zero-config: a self-configuring AdaptiveLauncher is the default — nothing to do.
$thread = new Thread(new MyTask());
$thread->start();

// Explicit configuration when you need it:
Thread::bindLauncher(new CliLauncher(
    binaryPath: '/usr/bin/php',
    runnerPath: __DIR__ . '/vendor/flytachi/winter-thread/wRunner',
    transport:  new TempFileTransport(),      // omit to auto-detect per launch
    secret:     'your-signing-secret',        // signs serialized closures
));

// Custom backend (Docker/SSH/…): implement Launcher and bind it directly.
Thread::bindLauncher(new MyCustomLauncher());

Swoole / Event-Loop Compatibility

When its transport is left unset, the launcher picks a pipe-free transport (TempFileTransport) if it detects an active Swoole runtime — pipe file descriptors from proc_open do not survive SWOOLE_HOOK_ALL intact.

Launching from inside a Swoole coroutine is supported. A pipe-free transport handles the payload, but the spawn is the other half: native proc_open contends with the reactor over the file-descriptor table from inside a coroutine. The default AdaptiveLauncher closes that gap — it routes to SwooleLauncher, which starts the runner as a shell background job (Coroutine\System::exec()) that never touches the reactor's fds. So in-coroutine dispatch works out of the box; plain CLI and FPM go through proc_open exactly as before. See docs/07.

Transport Delivery Parent pipe fd Requires
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:

$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.

Output Modes

$outputTarget Use case
'/dev/null' (default) Fire-and-forget: safe, output discarded
'/path/to/file.log' Persistent logging for staging/production
null (explicit) Piped to parent: read via readOutput() / readError()

Note: With null, join() and reap() drain the pipes internally while they wait, so a bare join() never deadlocks on a large output — and readOutput() after it returns the full buffered output. Use an explicit readOutput() poll loop only when you want the output live as it is produced.

Process Control

$thread->pause();     // SIGSTOP — suspend execution
$thread->resume();    // SIGCONT — resume after pause
$thread->terminate(); // SIGTERM — graceful shutdown request
$thread->kill();      // SIGKILL — force kill (last resort)
$thread->interrupt(); // SIGINT  — Ctrl+C equivalent
$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:

composer install
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

Containered — heavy, environment-specific checks (leak / timing / nested / battle-run, with Cli / FPM / Swoole), run inside Docker across a list of PHP versions:

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

# 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

The user-facing documentation lives at winterframe.net/packages/thread (the link picks your language; RU and EN are both complete).

Start here

Page What it answers
Introduction What it is, and why a process rather than a thread
Installation Requirements, bootstrap, verifying the install
Quick start A complete parallel example
Mental model Parent and child, and what crosses between them

Guides

Page What it answers
Queue worker A long-running consumer that survives restarts
Parallel tasks Fanning work out and collecting results
Graceful shutdown Stopping work without losing it
Debugging output Seeing what a child actually printed
Framework integration Binding a launcher once, app-wide

Reference

Page What it answers
API reference Every type, method and argument
Signals and exit codes What each signal and exit code means
Payload modes Pipe, temp file and shared memory

Deep dive

Page What it answers
Runner lifecycle What happens between start() and run()
Output and broken pipe Why fire-and-forget defaults to /dev/null
Swoole and payload delivery Launching from inside a coroutine
PID reuse and signals Why signalling a raw PID is risky
Security and performance Payload signing, and what it costs

Classes in this package carry an @link to their page, so the same documentation is one click away from your IDE.

Contributing

Internal technical notes — exact contracts, invariants, and the reasoning behind decisions that are not obvious from the code — live in docs/: fifteen pages from the launcher to architecture. Read those before changing how a process is spawned.

Contributions are welcome — open an issue or a pull request for bugs, questions or features.

License

This library is open-source software licensed under the MIT license.