<?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\Connection;
use seekquarry\atto\WebSite;
use seekquarry\atto\WindowUpdateFrame;
use seekquarry\yioop\library\UnitTest;
/**
* A WebSite that opens up the few protected HTTP/2 send-path pieces a
* test needs to drive a response by hand: setting up the per-stream
* flow-control windows, starting a paced response body, feeding one
* received frame in, draining whatever is queued to the client, and
* registering a fake connection so the drain can find it.
*/
class H2PacedResumeProbeWebSite extends WebSite
{
/**
* Sets up the connection's HTTP/2 send windows the way the server
* does right after the client's opening SETTINGS frame is read.
*
* @param object $conn connection holding the protocol state
* @param array $client_settings client settings, keyed by the
* HTTP/2 settings ids, used here to seed the per-stream
* initial window
* @return void
*/
public function initStreamWindows($conn, $client_settings)
{
$this->initH2SendWindows($conn, $client_settings);
}
/**
* Starts sending a response body on a stream, pacing it across the
* event loop when it is larger than the stream's send window.
*
* @param int $key connection identifier
* @param resource $socket socket the frames go out on
* @param object $conn connection holding the protocol state
* @param int $stream_id stream the body belongs to
* @param string $body the complete response body
* @return void
*/
public function startPacedBody($key, $socket, $conn, $stream_id,
$body)
{
$this->sendH2BodyPaced($key, $socket, $conn, $stream_id, $body);
}
/**
* Hands one received HTTP/2 frame to the server's frame handler,
* standing in for the event loop reading it off the wire.
*
* @param int $key connection identifier
* @param resource $socket socket the connection is on
* @param object $conn connection holding the protocol state
* @param object $frame the frame object to process
* @param string $payload the frame's raw body bytes
* @param int $length the frame body length in bytes
* @return mixed whatever the frame handler returns
*/
public function handleFrame($key, $socket, $conn, $frame, $payload,
$length)
{
return $this->processH2Frame($key, $socket, $conn, $frame,
$payload, $length);
}
/**
* Drains the connections that currently have unsent response bytes,
* writing as much as each socket will take, the way the event loop
* does once per pass.
*
* @return void
*/
public function drainResponses()
{
$this->processResponseStreams(
$this->out_streams[self::CONNECTION] ?? []);
}
/**
* Registers a connection and its socket so the drain step can find
* it, the way accepting a connection would.
*
* @param int $key connection identifier
* @param object $conn the connection object
* @param resource $socket the connection's socket
* @return void
*/
public function registerConnection($key, $conn, $socket)
{
$this->connections[$key] = $conn;
$this->in_streams[self::CONNECTION][$key] = $socket;
}
/**
* Reports whether the connection still has response bytes queued
* and not yet written to the client.
*
* @param int $key connection identifier
* @return bool true when bytes are still waiting to go out
*/
public function hasQueuedResponse($key)
{
return isset($this->out_streams[self::CONNECTION][$key])
&& isset($this->out_streams[self::DATA][$key]);
}
/**
* Reads and processes whatever has arrived on the connection's
* socket, the way the event loop does for a readable connection.
* This drives the real read path, including its handling of an
* end-of-file seen on the socket, rather than handing a frame
* straight to the frame handler.
*
* @param int $key connection identifier
* @param resource $socket the connection's socket to read from
* @return mixed whatever the read path returns
*/
public function readOnce($key, $socket)
{
return $this->parseH2Request($key, $socket);
}
}
/**
* A WebSite that records every frame the HTTP/2 read loop hands to the
* frame handler and reports the first one as a dispatched request, so a
* test can confirm the read loop keeps draining the buffer past that
* first dispatch instead of stopping there.
*/
class H2FrameDrainProbeWebSite extends WebSite
{
/**
* The stream ids of the frames the read loop handed off, in order,
* one entry per call.
* @var array
*/
public $seen = [];
/**
* Reads and processes whatever has arrived on the connection's
* socket, the way the event loop does for a readable connection.
*
* @param int $key connection identifier
* @param resource $socket the connection's socket to read from
* @return mixed whatever the read path returns
*/
public function readOnce($key, $socket)
{
return $this->parseH2Request($key, $socket);
}
/**
* Stands in for the real frame handler: records the frame's stream
* id and reports the very first frame as a dispatched request (a
* non-false return) and the rest as routine. The read loop must
* keep going after that first non-false return, so every frame in a
* single read shows up here.
*
* @param int $key connection identifier
* @param resource $connection the connection's socket
* @param object $conn connection holding the protocol state
* @param object $frame the parsed frame
* @param string $payload the frame's raw body bytes
* @param int $length the frame body length in bytes
* @return mixed true for the first frame, false afterward
*/
protected function processH2Frame($key, $connection, $conn, $frame,
$payload, $length)
{
$this->seen[] = $frame->stream_id;
return count($this->seen) === 1;
}
/**
* Registers a connection and its socket so the read path can find
* it, the way accepting a connection would.
*
* @param int $key connection identifier
* @param object $conn the connection object
* @param resource $socket the connection's socket
* @return void
*/
public function registerConnection($key, $conn, $socket)
{
$this->connections[$key] = $conn;
$this->in_streams[self::CONNECTION][$key] = $socket;
}
}
/**
* A WebSite that exposes the protected memory-sample timing check so a
* test can drive it with chosen clock values instead of the real time.
*/
class MemorySampleProbeWebSite extends WebSite
{
/**
* Sets the sample period the timing check reads, standing in for
* the value the running server takes from its config.
*
* @param int $seconds whole seconds between samples, zero for off
*/
public function setPeriod($seconds)
{
$this->memory_sample_period = $seconds;
}
/**
* Calls the protected timing check with a supplied current time.
*
* @param int $now pretend wall-clock time in whole seconds
* @return bool whether a memory sample is due at that time
*/
public function dueAt($now)
{
return $this->memorySampleDue($now);
}
}
/**
* Gathers the tests for the atto WebSite server in one place. Some cover
* the HTTP/2 send path: a large response must be delivered in full even
* when it does not fit the client's opening per-stream window, the body
* is sent up to the window then parks at a zero send window, and the
* rest must go out once the client returns credit with a WINDOW_UPDATE
* frame, guarding against the partial-transfer failure where the tail
* was never sent; another checks every request coalesced into one read
* is dispatched, not just the first. The HTTP/2 cases run over an
* in-memory socket pair, so they are deterministic and touch no network.
* The rest cover the switchable memory-usage sampling: how often a
* sample is due based on the period the server is configured with.
*
* @author Chris Pollett
*/
class WebSiteTest extends UnitTest
{
/**
* How many streams the closed-stream case opens, completes, and
* then sends a late WINDOW_UPDATE for. Chosen larger than the
* HTTP/2 max-concurrent-streams guard so that, before the fix,
* the run would have stashed enough stranded credit entries to
* trip the guard and close the connection.
*/
const CLOSED_STREAM_RUN = 120;
/**
* The socket pair used by a case, closed in tearDown so a case that
* throws part way through still releases its sockets.
* @var array
*/
public $sockets;
/**
* Forces the atto WebSite file to load before a case builds the
* server and frame objects, since that file is self-contained and
* defines WebSite, Connection, and the frame classes together.
*/
public function setUp()
{
class_exists('seekquarry\\atto\\WebSite');
$this->sockets = [];
}
/**
* Closes the socket pair a case opened.
*/
public function tearDown()
{
foreach ($this->sockets as $socket) {
if (is_resource($socket)) {
fclose($socket);
}
}
$this->sockets = [];
}
/**
* Builds a connection on one end of an in-memory socket pair with
* its HTTP/2 send windows set up like a real client handshake: a
* per-stream initial window the body will not fit in, and a large
* connection-level window so the connection window is never the
* limit.
*
* @param object $site the probe WebSite driving the exchange
* @param int $stream_window the client's per-stream initial window
* @return array [connection key, connection object, server socket]
*/
private function setUpConnection($site, $stream_window)
{
$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM,
0);
$server = $pair[0];
$client = $pair[1];
stream_set_blocking($server, false);
stream_set_blocking($client, false);
$this->sockets = [$server, $client];
$key = (int)$server;
$connection = new Connection($server, false);
$connection->protocol = 'h2';
$connection->is_websocket = false;
$site->initStreamWindows($connection,
[WebSite::H2_SETTING_INITIAL_WINDOW => $stream_window]);
$connection->protocol_state['send_window'] = 12436774;
$site->registerConnection($key, $connection, $server);
return [$key, $connection, $server];
}
/**
* Drains queued response bytes to the socket while reading the
* client end, so a full socket buffer never blocks further writes,
* and returns all bytes read from the client end so far appended to
* what was already collected.
*
* @param object $site the probe WebSite to drain
* @param int $key connection identifier to drain
* @param resource $client the client end to read from
* @param string $collected bytes read on earlier drains
* @return string $collected plus the newly read bytes
*/
private function drainAndRead($site, $key, $client, $collected)
{
for ($pass = 0; $pass < 2000
&& $site->hasQueuedResponse($key); $pass++) {
$site->drainResponses();
while (($chunk = fread($client, 65536)) !== false
&& $chunk !== "") {
$collected .= $chunk;
}
}
while (($chunk = fread($client, 65536)) !== false
&& $chunk !== "") {
$collected .= $chunk;
}
return $collected;
}
/**
* Sums the payload bytes of the DATA frames addressed to one stream
* inside a buffer of serialized HTTP/2 frames, and reports whether
* any of them carried the END_STREAM flag. Each frame is a nine
* byte header (three byte length, one byte type, one byte flags,
* four byte stream id) followed by its payload.
*
* @param string $buffer serialized frame bytes read from the client
* @param int $stream the stream id whose DATA payload to total
* @return array [total DATA payload bytes, whether END_STREAM seen]
*/
private function countDataPayload($buffer, $stream)
{
$position = 0;
$total = 0;
$length = strlen($buffer);
$end_seen = false;
while ($position + 9 <= $length) {
$frame_length = (ord($buffer[$position]) << 16)
| (ord($buffer[$position + 1]) << 8)
| ord($buffer[$position + 2]);
$type = ord($buffer[$position + 3]);
$flags = ord($buffer[$position + 4]);
$frame_stream = ((ord($buffer[$position + 5]) & 0x7f) << 24)
| (ord($buffer[$position + 6]) << 16)
| (ord($buffer[$position + 7]) << 8)
| ord($buffer[$position + 8]);
$position += 9;
if ($position + $frame_length > $length) {
break;
}
if ($type === 0x00 && $frame_stream === $stream) {
$total += $frame_length;
if ($flags & 0x01) {
$end_seen = true;
}
}
$position += $frame_length;
}
return [$total, $end_seen];
}
/**
* A response larger than the client's per-stream window is sent up
* to the window, parks, and then the rest goes out after a
* WINDOW_UPDATE returns credit, so the client receives the whole
* body with the final DATA frame marked END_STREAM.
*/
public function pacedResumeDeliversWholeBodyTestCase()
{
$site = new H2PacedResumeProbeWebSite("/");
list($key, $connection, $server) =
$this->setUpConnection($site, 131072);
$client = $this->sockets[1];
$stream = 15;
$body = str_repeat("A", 185349);
$site->startPacedBody($key, $server, $connection, $stream,
$body);
$received = $this->drainAndRead($site, $key, $client, "");
list($first_total, ) =
$this->countDataPayload($received, $stream);
$this->assertEqual(131072, $first_total,
'only the first window goes out before any credit returns');
$frame = new WindowUpdateFrame($stream);
$payload = pack("N", 12451840);
$site->handleFrame($key, $server, $connection, $frame,
$payload, 4);
$received = $this->drainAndRead($site, $key, $client, $received);
list($total, $end_seen) =
$this->countDataPayload($received, $stream);
$this->assertEqual(strlen($body), $total,
'the whole body reaches the client after the window update');
$this->assertTrue($end_seen,
'the final data frame carries the end of stream flag');
}
/**
* A response that fits the client's per-stream window is sent in one
* burst, reaching the client whole with the final DATA frame marked
* END_STREAM and needing no WINDOW_UPDATE.
*/
public function oneBurstDeliversWholeBodyTestCase()
{
$site = new H2PacedResumeProbeWebSite("/");
list($key, $connection, $server) =
$this->setUpConnection($site, 131072);
$client = $this->sockets[1];
$stream = 7;
$body = str_repeat("B", 40000);
$site->startPacedBody($key, $server, $connection, $stream,
$body);
$received = $this->drainAndRead($site, $key, $client, "");
list($total, $end_seen) =
$this->countDataPayload($received, $stream);
$this->assertEqual(strlen($body), $total,
'a body within the window is delivered in one burst');
$this->assertTrue($end_seen,
'the final data frame carries the end of stream flag');
}
/**
* The window-update credit is read off the socket through the real
* read path, and the client then closes its send side. Reading the
* credit must resume the parked body, and the end-of-file the close
* raises must not tear the stream down while the rest of the body is
* still queued, so the client receives the whole body with the final
* DATA frame marked END_STREAM. This guards the partial-transfer
* failure where a stalled response, woken only when the socket next
* went readable, was dropped at that wake because end-of-file was
* treated as a reason to close even though bytes were still pending.
*/
public function readCreditThenKeepsFlushingPastEofTestCase()
{
$site = new H2PacedResumeProbeWebSite("/");
list($key, $connection, $server) =
$this->setUpConnection($site, 131072);
$client = $this->sockets[1];
$stream = 15;
$body = str_repeat("A", 185349);
$site->startPacedBody($key, $server, $connection, $stream,
$body);
$received = $this->drainAndRead($site, $key, $client, "");
list($first_total, ) =
$this->countDataPayload($received, $stream);
$this->assertEqual(131072, $first_total,
'only the first window goes out before any credit returns');
$increment = 12451840;
$update = chr(0) . chr(0) . chr(4) . chr(0x08) . chr(0)
. pack("N", $stream & 0x7fffffff) . pack("N", $increment);
fwrite($client, $update);
stream_socket_shutdown($client, STREAM_SHUT_WR);
$site->readOnce($key, $server);
$site->readOnce($key, $server);
$received = $this->drainAndRead($site, $key, $client, $received);
list($total, $end_seen) =
$this->countDataPayload($received, $stream);
$this->assertEqual(strlen($body), $total,
'the whole body still reaches the client after the close');
$this->assertTrue($end_seen,
'the final data frame carries the end of stream flag');
}
/**
* Builds the wire bytes of one HTTP/2 frame: a nine byte header
* (three byte length, one byte type, one byte flags, four byte
* stream id) followed by the payload. The type here is DATA, which
* is enough for a test that only checks the read loop hands each
* framed-off chunk to the frame handler.
*
* @param int $stream the stream id to put in the frame header
* @param string $payload the frame body bytes
* @return string the serialized frame
*/
private function dataFrameBytes($stream, $payload)
{
$length = strlen($payload);
$header = chr(($length >> 16) & 0xff)
. chr(($length >> 8) & 0xff)
. chr($length & 0xff)
. chr(0x00)
. chr(0x00)
. pack("N", $stream & 0x7fffffff);
return $header . $payload;
}
/**
* Two requests that arrive together in a single read must both be
* handed off to the frame handler, not just the first. This guards
* the failure where, after one read carried several streams'
* frames, only the first stream got a response and the rest hung
* because nothing was left to wake the loop and read them.
*/
public function readDispatchesEveryRequestInOneReadTestCase()
{
$site = new H2FrameDrainProbeWebSite("/");
$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM,
0);
$server = $pair[0];
$client = $pair[1];
stream_set_blocking($server, false);
stream_set_blocking($client, false);
$this->sockets = [$server, $client];
$key = (int)$server;
$connection = new Connection($server, false);
$connection->protocol = 'h2';
$connection->is_websocket = false;
$connection->protocol_state = [];
$site->registerConnection($key, $connection, $server);
$first = $this->dataFrameBytes(1, "abc");
$second = $this->dataFrameBytes(3, "def");
fwrite($client, $first . $second);
$site->readOnce($key, $server);
$this->assertEqual([1, 3], $site->seen,
'both requests arriving in one read are handed off, not '
. 'only the first');
}
/**
* With no period configured, memory sampling stays off and the
* check never reports a sample as due, however much time passes.
*/
public function memorySampleOffWhenUnsetTestCase()
{
$site = new MemorySampleProbeWebSite("/");
$this->assertTrue(!$site->dueAt(1000),
"sampling is off when no period is configured");
$this->assertTrue(!$site->dueAt(500000),
"still off no matter how much time passes");
}
/**
* With a period of zero, which is how an operator turns the feature
* back off, the check still never reports a sample as due.
*/
public function memorySampleOffWhenZeroTestCase()
{
$site = new MemorySampleProbeWebSite("/");
$site->setPeriod(0);
$this->assertTrue(!$site->dueAt(1000),
"a period of zero turns sampling off");
}
/**
* With a positive period the first check is due, further checks are
* not due until the period has passed, and then become due again.
*/
public function memorySampleFiresEveryPeriodTestCase()
{
$site = new MemorySampleProbeWebSite("/");
$site->setPeriod(10);
$this->assertTrue($site->dueAt(1000),
"the first sample is due");
$this->assertTrue(!$site->dueAt(1005),
"a sample is not due before the period elapses");
$this->assertTrue($site->dueAt(1010),
"a sample is due once the period has elapsed");
$this->assertTrue(!$site->dueAt(1011),
"a sample is not due again right away");
}
/**
* Late WINDOW_UPDATE frames for streams the server has already
* finished and closed - which a browser sends as it drains each
* completed response - are ignored rather than stashed as
* pending credit, so a connection that serves a long run of
* responses (a video fetched range by range) never accumulates
* one stranded credit entry per closed stream and never trips
* the pending-credit guard that would close the connection.
*/
public function closedStreamWindowUpdateForClosedStreamIgnoredTestCase()
{
$site = new H2PacedResumeProbeWebSite("/");
list($key, $connection, $server) =
$this->setUpConnection($site, 131072);
$client = $this->sockets[1];
$closed_count = self::CLOSED_STREAM_RUN;
for ($index = 0; $index < $closed_count; $index++) {
$stream = 1 + 2 * $index;
$site->startPacedBody($key, $server, $connection, $stream,
"a small whole body that completes at once");
$this->drainAndRead($site, $key, $client, "");
$frame = new WindowUpdateFrame($stream);
$site->handleFrame($key, $server, $connection, $frame,
pack("N", 65536), 4);
}
$credit = $connection->protocol_state[
'pending_stream_window_credit'] ?? [];
$this->assertEqual(0, count($credit),
'no credit is stashed for streams that already closed');
$this->assertTrue($site->connection($key) !== null,
'the connection is not torn down by a guard tripping');
}
/**
* A WINDOW_UPDATE that arrives for a stream the server has opened
* but not yet begun responding on is still held as pending credit
* and still applied when the response starts, so a browser that
* enlarges a stream's window the instant it opens (the grant that
* lets a larger-than-window page go out in full) is unaffected by
* the closed-stream check.
*/
public function preGrantForOpenStreamStillAppliedTestCase()
{
$site = new H2PacedResumeProbeWebSite("/");
list($key, $connection, $server) =
$this->setUpConnection($site, 65536);
$client = $this->sockets[1];
$stream = 7;
$frame = new WindowUpdateFrame($stream);
$site->handleFrame($key, $server, $connection, $frame,
pack("N", 65536), 4);
$credit = $connection->protocol_state[
'pending_stream_window_credit'] ?? [];
$this->assertEqual(65536, $credit[$stream] ?? 0,
'a pre-grant for an open stream is held, not ignored');
$body = str_repeat("B", 200000);
$site->startPacedBody($key, $server, $connection, $stream,
$body);
$received = $this->drainAndRead($site, $key, $client, "");
list($first_total, ) =
$this->countDataPayload($received, $stream);
$this->assertEqual(131072, $first_total,
'the held pre-grant is added to the initial window so two '
. 'windows worth go out before any further credit');
}
}