<?php
declare(strict_types=1);

class Wheel
{
    const SIGNAL_NUMBER        = 'signo';
    const MAX_NUMBER_OF_SPOKES = 3;
    protected $_spokes      = [];
    protected $_waitSignals = [
        SIGCHLD,
        SIGALRM,
        SIGINT,
        SIGTERM,
    ];
    protected $_info        = [];
    protected $_spoke;

    public function start(): Wheel
    {
        $this->_initialize();
        while ($this->getNumberOfSpokes() < self::MAX_NUMBER_OF_SPOKES) {
            $spoke = new Spoke();
            $spoke->appendPath($this->_getSpoke()->getPath());
            $spoke->start();
            $this->_spokes[$spoke->getProcessId()] = $spoke;
        }
        pcntl_sigprocmask(SIG_BLOCK, $this->_waitSignals);

        while (true) {
            $this->_info = [];
            pcntl_sigwaitinfo($this->_waitSignals, $this->_info);

            switch ($this->_info[self::SIGNAL_NUMBER]) {
                case SIGCHLD:
                    while ($processId = pcntl_wait($status, WNOHANG)) {
                        unset($this->_spokes[$processId]);
                        $spoke = new Spoke();
                        $spoke->appendPath($this->_getSpoke()->getPath());
                        $spoke->start();
                        $this->_spokes[$spoke->getProcessId()] = $spoke;
                    }
                    break;
                case SIGALRM:
                    pcntl_alarm(8);
                    break;
                case SIGINT:
                case SIGTERM:
                    $this->terminateChildProcesses();
                    break 2;
                default:
                    throw new \UnexpectedValueException('Unexpected blocked signal.');
            }
        }
    }

    public function setSpoke(Spoke $spoke): Wheel
    {
        $this->_spoke = $spoke;

        return $this;
    }

    protected function _getSpoke(): Spoke
    {
        if ($this->_spoke === null) {
            throw new \LogicException('Spoke is not set.');
        }

        return $this->_spoke;
    }

    protected function _initialize(): Wheel
    {
        register_shutdown_function([$this, 'terminateChildProcesses']);
        pcntl_signal(SIGTERM, [$this, 'terminateChildProcesses']);
        pcntl_signal(SIGINT, [$this, 'terminateChildProcesses']);

        return $this;
    }

    public function terminateChildProcesses(): Wheel
    {
        if (!empty($this->_processes)) {
            /** @var Spoke $spoke */
            foreach ($this->_spokes as $spoke) {
                $processId = $spoke->getProcessId();
                if (rand(1, 0)) {
                    posix_kill($processId, SIGKILL);
                }else {
                    posix_kill($processId, SIGTERM);
                }
                unset($this->_processes[$processId]);
            }
        }

        return $this;
    }

    public function getNumberOfSpokes(): int
    {
        return count($this->_spokes);
    }
}

class Spoke
{
    const FORK_FAILURE_CODE = -1;
    protected $_parentProcessId;
    protected $_processId;
    protected $_wheel;
    protected $_path = '';

    public function start(): Spoke
    {
        $processId = pcntl_fork();
        if ($processId === self::FORK_FAILURE_CODE) {
            throw new \RuntimeException('Failed to fork a new spoke.');
        }elseif ($processId > 0) {
            $this->_initialize($processId);
        }else {
            $this->_initialize();
            if (substr_count($this->getPath(), '/') <= 3) {
                $this->_startNewWheel();
            }
            exit(0);
        }

        return $this;
    }

    public function getProcessId(): int
    {
        if ($this->_processId === null) {
            throw new \LogicException('Process ID is not set.');
        }

        return $this->_processId;
    }

    protected function _startNewWheel(): Spoke
    {
        $this->_wheel = new Wheel();
        $this->_wheel->setSpoke($this);
        $this->_wheel->start();

        return $this;
    }

    protected function _initialize(int $processId = null): Spoke
    {
        if ($processId === null) {
            $this->_parentProcessId = posix_getppid();
            $this->_processId = posix_getpid();
            $this->appendPath((string)$this->getProcessId());
        }else {
            $this->_processId = $processId;
        }

        return $this;
    }

    public function getPath(): string
    {
        return $this->_path;
    }

    public function appendPath(string $path): Spoke
    {
        $this->_path .= '/' . $path;

        return $this;
    }
}

$spoke = new Spoke();
$spoke->start();

return;