/ tests / CooperativeSchedulerTest.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see
 * <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\atto\CooperativeScheduler;
use seekquarry\atto\WebSite;
use seekquarry\yioop\library\UnitTest;

/**
 * A WebSite that reports it is not the atto CLI loop, used to exercise
 * the non-atto (Apache and the like) path of deferTask, where the work
 * has to run straight through because there is no event loop to spread
 * it across.
 */
class ApacheModeWebSite extends WebSite
{
    /**
     * Pretends the server is not the atto CLI loop.
     * @return bool always false
     */
    public function isCli()
    {
        return false;
    }
}

/**
 * A WebSite that exposes its per-request fields so the round trip of
 * captureRequestEnvironment() and applyRequestEnvironment() can be
 * checked against them as well as against the superglobals.
 */
class RequestEnvironmentProbeWebSite extends WebSite
{
    /**
     * Sets the three per-request fields the environment snapshot covers.
     *
     * @param string $header value for the response header buffer
     * @param mixed $content_type value for the response content type
     * @param string $session value for the current session id
     * @return void
     */
    public function setRequestFields($header, $content_type, $session)
    {
        $this->header_data = $header;
        $this->content_type = $content_type;
        $this->current_session = $session;
    }
    /**
     * Reads back the three per-request fields the snapshot covers.
     *
     * @return array [header buffer, content type, current session id]
     */
    public function getRequestFields()
    {
        return [$this->header_data, $this->content_type,
            $this->current_session];
    }
}

/**
 * A WebSite that lets a test stand in for the protocol layer around a
 * deferred request: it sets the HTTP/1.1 send context, drives the
 * cooperative tasks the event loop would drive, and reads back what was
 * queued to be written to the client.
 */
class CoopProbeWebSite extends WebSite
{
    /**
     * Pretends an HTTP/1.1 request is in flight on the given socket, the
     * way the real H1 path does just before it runs the route.
     *
     * @param resource $socket the request's connection
     * @return void
     */
    public function setStreamingH1($socket)
    {
        $this->streaming_socket = $socket;
        $this->streaming_context = ['protocol' => 'h1'];
    }
    /**
     * Drives the cooperative tasks to completion the way the event loop
     * would across passes, with a guard so a stuck task cannot hang the
     * test.
     * @return void
     */
    public function driveTasks()
    {
        $guard = 0;
        while ($this->cooperative_scheduler !== null
            && !$this->cooperative_scheduler->isEmpty() && $guard < 100) {
            $this->cooperative_scheduler->step();
            $guard++;
        }
    }
    /**
     * Reads back the bytes queued to be written to the client on a
     * connection, or null if nothing was queued.
     *
     * @param int $key the connection's stream key
     * @return string|null the queued response bytes
     */
    public function outStreamData($key)
    {
        return $this->out_streams[self::DATA][$key] ?? null;
    }
}

/**
 * Tests that the cooperative scheduler resumes several fibers a little at
 * a time and interleaves them, hands a finished task's result (or the
 * exception it threw) to its completion callback, and stops a task that
 * runs past its work-time budget.
 *
 * @author Chris Pollett
 */
class CooperativeSchedulerTest extends UnitTest
{
    /**
     * No shared setup is needed beyond making sure the scheduler class
     * is loaded: it is defined inside the WebSite atto file (each atto
     * file is self-contained), so force that file to load before the
     * tests build a scheduler.
     */
    public function setUp()
    {
        class_exists('seekquarry\\atto\\WebSite');
    }
    /**
     * No teardown is needed; the scheduler holds no external state.
     */
    public function tearDown()
    {
    }
    /**
     * Two tasks added together are resumed once each per pass rather than
     * one running to the end before the other starts, so their recorded
     * steps interleave.
     */
    public function interleaveTestCase()
    {
        $log = [];
        $make = function ($name) use (&$log) {
            return new \Fiber(function () use ($name, &$log) {
                for ($index = 1; $index <= 3; $index++) {
                    $log[] = $name . $index;
                    \Fiber::suspend();
                }
            });
        };
        $scheduler = new CooperativeScheduler();
        $scheduler->add($make('A'));
        $scheduler->add($make('B'));
        for ($pass = 0; $pass < 8 && !$scheduler->isEmpty(); $pass++) {
            $scheduler->step();
        }
        $this->assertEqual(['A1', 'B1', 'A2', 'B2', 'A3', 'B3'], $log,
            'the two tasks interleave a little at a time');
        $this->assertTrue($scheduler->isEmpty(),
            'both tasks are dropped once finished');
    }
    /**
     * A task that finishes normally hands its return value to the
     * completion callback with no error.
     */
    public function completionResultTestCase()
    {
        $captured = ['called' => false, 'result' => null,
            'error' => 'unset'];
        $fiber = new \Fiber(function () {
            \Fiber::suspend();
            \Fiber::suspend();
            return 42;
        });
        $scheduler = new CooperativeScheduler();
        $scheduler->add($fiber,
            function ($result, $error) use (&$captured) {
                $captured['called'] = true;
                $captured['result'] = $result;
                $captured['error'] = $error;
            });
        for ($pass = 0; $pass < 6 && !$scheduler->isEmpty(); $pass++) {
            $scheduler->step();
        }
        $this->assertTrue($captured['called'],
            'the completion callback runs when the task finishes');
        $this->assertEqual(42, $captured['result'],
            'the fiber return value reaches the callback');
        $this->assertTrue($captured['error'] === null,
            'a clean finish reports no error');
    }
    /**
     * A task that throws hands the exception to the completion callback
     * as the error, with a null result, and is dropped.
     */
    public function exceptionTestCase()
    {
        $captured = ['result' => 'unset', 'error' => null];
        $fiber = new \Fiber(function () {
            \Fiber::suspend();
            throw new \RuntimeException('boom');
        });
        $scheduler = new CooperativeScheduler();
        $scheduler->add($fiber,
            function ($result, $error) use (&$captured) {
                $captured['result'] = $result;
                $captured['error'] = $error;
            });
        for ($pass = 0; $pass < 4 && !$scheduler->isEmpty(); $pass++) {
            $scheduler->step();
        }
        $this->assertTrue(
            $captured['error'] instanceof \RuntimeException,
            'the thrown exception reaches the callback as the error');
        $this->assertTrue($captured['result'] === null,
            'a thrown task reports no result');
        $this->assertTrue($scheduler->isEmpty(),
            'a thrown task is dropped');
    }
    /**
     * A task that keeps working without finishing is stopped once it
     * passes its work-time budget, and the budget error reaches the
     * completion callback.
     */
    public function budgetAbortTestCase()
    {
        $captured = ['error' => null];
        $fiber = new \Fiber(function () {
            while (true) {
                usleep(2000);
                \Fiber::suspend();
            }
        });
        $scheduler = new CooperativeScheduler(0.001);
        $scheduler->add($fiber,
            function ($result, $error) use (&$captured) {
                $captured['error'] = $error;
            });
        $scheduler->step();
        $this->assertTrue(
            $captured['error'] instanceof \RuntimeException,
            'an over-budget task is stopped with an error');
        $this->assertTrue(
            strpos($captured['error']->getMessage(), 'budget') !==
            false,
            'the error names the budget as the reason');
        $this->assertTrue($scheduler->isEmpty(),
            'an over-budget task is dropped');
    }
    /**
     * Under a non-atto web server (Apache and the like) there is no
     * event loop, so deferTask runs the work straight through, outside
     * any fiber, and hands its result to the completion callback before
     * returning, rather than parking it for a loop that will never come.
     */
    public function deferTaskInlineWithoutLoopTestCase()
    {
        $site = new ApacheModeWebSite("test");
        $ran = [];
        $task = function () use (&$ran) {
            $ran[] = 'a';
            $ran[] = 'b';
            return 'done';
        };
        $captured = ['result' => null, 'error' => 'unset'];
        $site->deferTask($task,
            function ($result, $error) use (&$captured) {
                $captured['result'] = $result;
                $captured['error'] = $error;
            });
        $this->assertEqual(['a', 'b'], $ran,
            'the task runs to completion straight away');
        $this->assertEqual('done', $captured['result'],
            'the return value reaches the completion callback');
        $this->assertTrue($captured['error'] === null,
            'a clean run reports no error');
    }
    /**
     * captureRequestEnvironment() then applyRequestEnvironment() puts the
     * superglobals and the per-request fields back to what they were at
     * capture time, even after another request has overwritten them in
     * between. This is the swap a cooperative request relies on to resume
     * in its own context after the loop has served others.
     */
    public function requestEnvironmentRoundTripTestCase()
    {
        $site = new RequestEnvironmentProbeWebSite("test");
        $_GET ??= [];
        $_POST ??= [];
        $_REQUEST ??= [];
        $_COOKIE ??= [];
        $_SESSION ??= [];
        $original = $site->captureRequestEnvironment();
        $_GET = ['k' => 'first'];
        $_POST = ['p' => 'first'];
        $_REQUEST = ['k' => 'first'];
        $_COOKIE = ['c' => 'first'];
        $_SESSION = ['s' => 'first'];
        $_SERVER['REQUEST_URI'] = '/first';
        $site->setRequestFields('HEADER-first', 'text/first',
            'session-first');
        $saved = $site->captureRequestEnvironment();
        $_GET = ['k' => 'second'];
        $_POST = ['p' => 'second'];
        $_REQUEST = ['k' => 'second'];
        $_COOKIE = ['c' => 'second'];
        $_SESSION = ['s' => 'second'];
        $_SERVER['REQUEST_URI'] = '/second';
        $site->setRequestFields('HEADER-second', 'text/second',
            'session-second');
        $site->applyRequestEnvironment($saved);
        $this->assertEqual('first', $_GET['k'],
            'GET is restored to the saved request');
        $this->assertEqual('first', $_POST['p'],
            'POST is restored to the saved request');
        $this->assertEqual('first', $_REQUEST['k'],
            'REQUEST is restored to the saved request');
        $this->assertEqual('first', $_COOKIE['c'],
            'COOKIE is restored to the saved request');
        $this->assertEqual('first', $_SESSION['s'],
            'SESSION is restored to the saved request');
        $this->assertEqual('/first', $_SERVER['REQUEST_URI'],
            'SERVER is restored to the saved request');
        $fields = $site->getRequestFields();
        $this->assertEqual('HEADER-first', $fields[0],
            'the header buffer is restored to the saved request');
        $this->assertEqual('text/first', $fields[1],
            'the content type is restored to the saved request');
        $this->assertEqual('session-first', $fields[2],
            'the current session is restored to the saved request');
        $site->applyRequestEnvironment($original);
    }
    /**
     * Under a non-atto web server (Apache and the like) deferResponse runs
     * the handler straight through, since there is no event loop to spread
     * it across.
     */
    public function deferResponseInlineUnderApacheTestCase()
    {
        $site = new ApacheModeWebSite("test");
        $ran = false;
        $handler = function () use (&$ran) {
            $ran = true;
        };
        $handled = $site->deferResponse($handler);
        $this->assertTrue($ran,
            'under Apache the handler runs immediately');
        $this->assertTrue($handled === true,
            'deferResponse reports the request handled');
    }
    /**
     * Under the event loop a deferred HTTP/1.1 request runs its handler
     * inside a fiber, in its own request environment, and the finished
     * page is queued to the client with a status line and a correct
     * content length once the fiber completes. Changing the superglobals
     * after deferring proves the handler keeps its own captured context.
     */
    public function deferredH1RoundTripTestCase()
    {
        $site = new CoopProbeWebSite("test");
        $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
        $_GET = ['q' => 'captured'];
        $_POST ??= [];
        $_REQUEST ??= [];
        $_COOKIE ??= [];
        $_SESSION ??= [];
        $socket = fopen('php://memory', 'r+');
        $site->setStreamingH1($socket);
        $handler = function () {
            echo "body[" . ($_GET['q'] ?? '') . "]";
        };
        $handled = $site->deferResponse($handler);
        $_GET = ['q' => 'changed-after'];
        $site->driveTasks();
        $out = $site->outStreamData((int) $socket);
        $this->assertTrue($handled === true,
            'deferResponse reports the request handled');
        $this->assertTrue(is_string($out) &&
            strpos($out, "body[captured]") !== false,
            'the handler runs with its own captured GET, not the later one');
        $this->assertTrue(is_string($out) &&
            strpos($out, "200 OK") !== false,
            'a status line is added to the deferred response');
        $this->assertTrue(is_string($out) &&
            strpos($out, "Content-Length: 14") !== false,
            'a correct content length is added');
        fclose($socket);
    }
    /**
     * A deferred handler that calls webExit() -- the way a redirect or any
     * normal early exit ends a Yioop request -- must finish cleanly: the
     * output written before the exit is sent, and it is not turned into a
     * 500 just because the exit threw under the atto loop.
     */
    public function deferredWebExitFinishesCleanlyTestCase()
    {
        $site = new CoopProbeWebSite("test");
        $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
        $_GET ??= [];
        $_POST ??= [];
        $_REQUEST ??= [];
        $_COOKIE ??= [];
        $_SESSION ??= [];
        $socket = fopen('php://memory', 'r+');
        $site->setStreamingH1($socket);
        $handler = function () {
            echo "before-exit";
            \seekquarry\atto\webExit("done");
            echo "after-exit";
        };
        $site->deferResponse($handler);
        $site->driveTasks();
        $out = $site->outStreamData((int) $socket);
        $this->assertTrue(is_string($out) &&
            strpos($out, "before-exit") !== false,
            'output written before webExit is sent');
        $this->assertTrue(is_string($out) &&
            strpos($out, "after-exit") === false,
            'webExit stops the handler so later output is not produced');
        $this->assertTrue(is_string($out) &&
            strpos($out, "500") === false,
            'a deferred webExit is a clean finish, not a server error');
        fclose($socket);
    }
    /**
     * A task that suspended with a plain Fiber::suspend() (a CPU- or
     * disk-bound job pausing between chunks) counts as immediate work, so
     * the loop wakes at once to resume it; a task that suspended waiting
     * on a socket does not, so the loop may poll it gently instead.
     */
    public function immediateWorkTestCase()
    {
        $scheduler = new CooperativeScheduler();
        $cpu_task = new \Fiber(function () {
            \Fiber::suspend();
            \Fiber::suspend();
        });
        $scheduler->add($cpu_task);
        $this->assertTrue($scheduler->hasImmediateWork(),
            "a not-yet-started task counts as immediate work");
        $scheduler->step();
        $this->assertTrue($scheduler->hasImmediateWork(),
            "a plain suspend wants to be resumed right away");
        $io_scheduler = new CooperativeScheduler();
        $io_task = new \Fiber(function () {
            $socket = fopen('php://memory', 'r+');
            \Fiber::suspend(['read' => $socket]);
        });
        $io_scheduler->add($io_task);
        $io_scheduler->step();
        $this->assertFalse($io_scheduler->hasImmediateWork(),
            "a task waiting only on a socket is not immediate work");
    }
}
X