<?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");
}
}