/ tests / WebSiteTest.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\Connection;
use seekquarry\atto\WebSocket;
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);
    }
}

/**
 * Reaches the session storage of a WebSite so that writing, reading back
 * and dropping a session can be checked without running a server.
 */
class SessionFileProbeWebSite extends WebSite
{
    /**
     * Points sessions at a directory of the caller's choosing and sets how
     * large one may grow, standing in for what a running server takes from
     * its configuration.
     *
     * @param string $directory where session files should be written
     * @param int $largest how many bytes a written session may reach
     */
    public function useDirectory($directory, $largest)
    {
        $this->default_server_globals['SESSION_DIR'] = $directory;
        $this->default_server_globals['MAX_REQUEST_LEN'] = $largest;
    }
    /**
     * Writes a session's contents through the storage under test.
     *
     * @param string $session_id the session to write
     * @param array $session_data the contents to write
     * @return bool whether the contents were written
     */
    public function write($session_id, $session_data)
    {
        return $this->writeSessionData($session_id, $session_data);
    }
    /**
     * Drops a session and the file its contents were kept in.
     *
     * @param string $session_id the session to drop
     */
    public function forget($session_id)
    {
        $this->forgetSession($session_id);
    }
    /**
     * The path a session's contents are kept at.
     *
     * @param string $session_id the session to name a file for
     * @return string absolute path of that session's file
     */
    public function pathOf($session_id)
    {
        return $this->sessionFilePath($session_id);
    }
}

/**
 * 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;
    /**
     * Connection key the test writes against, standing for one client.
     * @var int
     */
    public $key = 7;
    /**
     * The server whose outbound framing is under test.
     * @var WebSite
     */
    public $site;
    /**
     * The streams a server reads from, reached through reflection
     * because a server keeps them to itself. A write is dropped unless
     * its connection is still among them.
     * @var \ReflectionProperty
     */
    public $in_streams;
    /**
     * The streams a server writes to, reached through reflection because
     * a server keeps them to itself. What a test wants to read is the
     * bytes waiting here.
     * @var \ReflectionProperty
     */
    public $out_streams;
    /**
     * The method that wraps socket frames in data frames, reached
     * through reflection.
     * @var \ReflectionMethod
     */
    public $enqueue_h2;
    /**
     * The method that picks which way a socket frame is written,
     * reached through reflection.
     * @var \ReflectionMethod
     */
    public $send_frame;
    /**
     * The method that says whether a drained connection carries nothing
     * but a socket, reached through reflection.
     * @var \ReflectionMethod
     */
    public $uses_socket_drain;
    /**
     * 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, then
     * stands up a server and a connection for the cases that check how
     * socket frames are written.
     */
    public function setUp()
    {
        class_exists('seekquarry\\atto\\WebSite');
        $this->sockets = [];
        $reflection = new \ReflectionClass(WebSite::class);
        $this->site = $reflection->newInstanceWithoutConstructor();
        /* what a class keeps to itself has been reachable through
           reflection without asking since PHP 8.1, and asking is an
           error as of 8.5, so nothing is asked for here. */
        $this->in_streams = new \ReflectionProperty(WebSite::class,
            "in_streams");
        $this->out_streams = new \ReflectionProperty(WebSite::class,
            "out_streams");
        $this->enqueue_h2 = new \ReflectionMethod(WebSite::class,
            "wsEnqueueH2Write");
        $this->send_frame = new \ReflectionMethod(WebSite::class,
            "wsSendFrame");
        $this->uses_socket_drain = new \ReflectionMethod(WebSite::class,
            "usesSocketDrain");
        $this->in_streams->setValue($this->site,
            [WebSite::CONNECTION => [$this->key => "a-connection"]]);
        $this->out_streams->setValue($this->site, []);
    }
    /**
     * 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');
    }
    /**
     * A session written to its file reads back as what was put in it, and
     * dropping the session takes the file with it, so an evicted session
     * leaves nothing behind on disk.
     */
    public function sessionRoundTripsThroughItsFileTestCase()
    {
        $probe = new SessionFileProbeWebSite();
        $probe->useDirectory(sys_get_temp_dir(), 10000);
        $identifier = "probe" . getmypid();
        $this->assertTrue($probe->write($identifier, ['USER_ID' => 7]),
            "a small session is written");
        $this->assertTrue(file_exists($probe->pathOf($identifier)),
            "its file is there");
        $read = $probe->sessionData($identifier);
        $this->assertEqual($read['USER_ID'] ?? 0, 7,
            "what was put in reads back");
        $probe->forget($identifier);
        $this->assertTrue(!file_exists($probe->pathOf($identifier)),
            "dropping the session takes its file too");
    }
    /**
     * A session grown past what the server accepts as a request body is
     * refused rather than written, since holding one that large would cost
     * the memory that limit exists to bound.
     */
    public function oversizeSessionIsRefusedTestCase()
    {
        $probe = new SessionFileProbeWebSite();
        $probe->useDirectory(sys_get_temp_dir(), 100);
        $identifier = "oversize" . getmypid();
        $this->assertTrue(!$probe->write($identifier,
            ['BIG' => str_repeat("x", 500)]),
            "a session past the limit is refused");
        $this->assertTrue(!file_exists($probe->pathOf($identifier)),
            "nothing is written for it");
        $this->assertEqual($probe->sessionData($identifier), [],
            "and it reads back as empty");
    }
    /**
     * 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');
    }
    /**
     * Gives back the bytes waiting to be written for the test's
     * connection.
     *
     * @return string bytes queued so far
     */
    private function queued()
    {
        $streams = $this->out_streams->getValue($this->site);
        return $streams[WebSite::DATA][$this->key] ?? "";
    }
    /**
     * Reads a run of HTTP/2 frames and reports what each one is. Every
     * frame is nine bytes of heading and then its body, so a reader can
     * walk them without knowing what they carry.
     *
     * @param string $bytes the queued bytes to read
     * @return array one entry per frame, each with its type, flags,
     *      stream id and body
     */
    private function framesIn($bytes)
    {
        $frames = [];
        $offset = 0;
        while ($offset + 9 <= strlen($bytes)) {
            $length = (ord($bytes[$offset]) << 16) +
                (ord($bytes[$offset + 1]) << 8) + ord($bytes[$offset + 2]);
            $type = ord($bytes[$offset + 3]);
            $flags = ord($bytes[$offset + 4]);
            $stream_id = unpack('N',
                substr($bytes, $offset + 5, 4))[1] & 0x7FFFFFFF;
            $body = substr($bytes, $offset + 9, $length);
            $frames[] = ['type' => $type, 'flags' => $flags,
                'stream_id' => $stream_id, 'body' => $body];
            $offset += 9 + $length;
        }
        return $frames;
    }
    /**
     * Builds a socket handle that rides the given HTTP/2 stream, or a
     * plain one when the stream is zero.
     *
     * @param int $stream_id the stream the socket rides, zero for none
     * @return WebSocket the socket handle
     */
    private function socketOnStream($stream_id)
    {
        $socket = new WebSocket("a-connection", $this->key, $this->site);
        $socket->h2_stream_id = $stream_id;
        return $socket;
    }
    /**
     * A socket frame small enough to fit goes out as a single data
     * frame on its own stream, carrying exactly what was given.
     */
    public function smallFrameGoesAsOneDataFrameTestCase()
    {
        $payload = str_repeat("x", 40);
        $this->enqueue_h2->invoke($this->site, $this->key, 5, $payload);
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 1,
            "a small socket frame goes out as one data frame");
        $this->assertEqual($frames[0]['type'], 0,
            "the frame it goes out in is a data frame");
        $this->assertEqual($frames[0]['stream_id'], 5,
            "the data frame rides the stream it was given");
        $this->assertEqual($frames[0]['body'], $payload,
            "the data frame carries what was given, unchanged");
        $this->assertEqual($frames[0]['flags'], 0,
            "carrying a socket frame does not end the stream");
    }
    /**
     * A socket frame larger than a reader will take in one data frame is
     * split across several, and what arrives, joined back up, is what
     * was sent. A single oversized frame had been dropped by readers.
     */
    public function largeFrameIsSplitToFrameSizeTestCase()
    {
        $limit = WebSite::H2_MAX_FRAME_SIZE;
        $payload = str_repeat("y", 2 * $limit + 100);
        $this->enqueue_h2->invoke($this->site, $this->key, 9, $payload);
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 3,
            "a frame over twice the size goes out as three");
        $joined = "";
        foreach ($frames as $frame) {
            $this->assertTrue(strlen($frame['body']) <= $limit,
                "no data frame carries more than a reader will take");
            $this->assertEqual($frame['stream_id'], 9,
                "every piece rides the same stream");
            $joined .= $frame['body'];
        }
        $this->assertEqual($joined, $payload,
            "the pieces joined back up are what was sent");
    }
    /**
     * A socket frame of exactly the size a reader will take goes out as
     * one data frame, with no empty one following it.
     */
    public function frameOfExactlyTheLimitIsNotSplitTestCase()
    {
        $payload = str_repeat("z", WebSite::H2_MAX_FRAME_SIZE);
        $this->enqueue_h2->invoke($this->site, $this->key, 3, $payload);
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 1,
            "a frame of exactly the limit goes out as one");
        $this->assertEqual(strlen($frames[0]['body']),
            WebSite::H2_MAX_FRAME_SIZE,
            "that one frame carries the whole of it");
    }
    /**
     * An answer sent on a socket that rides an HTTP/2 stream is wrapped
     * in a data frame. Writing it as raw bytes had left those bytes
     * where a frame heading was looked for, so everything sent after it
     * on that connection could not be read.
     */
    public function answerOnACarriedSocketIsWrappedTestCase()
    {
        $socket = $this->socketOnStream(11);
        $this->send_frame->invoke($this->site, $this->key, $socket,
            "\x88\x02\x03\xe8");
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 1,
            "the answer goes out as one data frame");
        $this->assertEqual($frames[0]['type'], 0,
            "the answer is wrapped in a data frame");
        $this->assertEqual($frames[0]['stream_id'], 11,
            "the wrapping rides the socket's own stream");
        $this->assertEqual($frames[0]['body'], "\x88\x02\x03\xe8",
            "the wrapping carries the answer unchanged");
    }
    /**
     * An answer sent on a socket that has a connection to itself goes
     * out as it stands, since there is no framing around it to keep.
     */
    public function answerOnAPlainSocketGoesAsItStandsTestCase()
    {
        $socket = $this->socketOnStream(0);
        $this->send_frame->invoke($this->site, $this->key, $socket,
            "\x8a\x00");
        $this->assertEqual($this->queued(), "\x8a\x00",
            "a plain socket's answer goes out as it stands");
    }
    /**
     * A write for a connection that has been taken down is let go
     * rather than reaching for a connection that is no longer there.
     */
    public function writeForAGoneConnectionIsDroppedTestCase()
    {
        $this->in_streams->setValue($this->site,
            [WebSite::CONNECTION => []]);
        $socket = $this->socketOnStream(13);
        $this->send_frame->invoke($this->site, $this->key, $socket,
            "\x81\x01a");
        $this->assertEqual($this->queued(), "",
            "nothing is queued for a connection that is gone");
    }
    /**
     * Builds a connection standing at a given protocol, saying whether
     * it carries a socket.
     *
     * @param string $protocol what the connection speaks
     * @param bool $is_websocket whether it carries a socket
     * @return Connection the connection
     */
    private function connectionSpeaking($protocol, $is_websocket)
    {
        $reflection = new \ReflectionClass(Connection::class);
        $connection = $reflection->newInstanceWithoutConstructor();
        $connection->protocol = $protocol;
        $connection->is_websocket = $is_websocket;
        return $connection;
    }
    /**
     * Checks that a reply named as a file is drained from disk a piece at
     * a time rather than read into a string. A pack built for a git pull
     * can be larger than the memory the server has, and gathering it as a
     * string exhausted the process on a live site.
     */
    public function fileBackedReplyDrainsInPiecesTestCase()
    {
        $site = new WebSite();
        $path = tempnam(sys_get_temp_dir(), "yioop_reply_");
        $piece = str_repeat("a line of the reply\n", 20000);
        $handle = fopen($path, "wb");
        for ($count = 0; $count < 4; $count++) {
            fwrite($handle, $piece);
        }
        fclose($handle);
        $whole = filesize($path);
        $key = "test-file-reply";
        $naming = new \ReflectionMethod($site, "sendRestFromFile");
        $this->assertTrue($naming->invoke($site, $key, $path),
            "a reply may be named as a file");
        $refill = new \ReflectionMethod($site, "refillFromFile");
        $reading = new \ReflectionProperty($site, "out_streams");
        $sent = 0;
        $largest = 0;
        for ($step = 0; $step < 200; $step++) {
            $refill->invoke($site, $key);
            $streams = $reading->getValue($site);
            $held = $streams[WebSite::DATA][$key] ?? "";
            if (empty($streams[WebSite::DATA_FILE][$key]) && $held === "") {
                break;
            }
            $sent += strlen($held);
            $largest = max($largest, strlen($held));
            $streams[WebSite::DATA][$key] = "";
            $reading->setValue($site, $streams);
        }
        unlink($path);
        $this->assertEqual($sent, $whole,
            "every byte of the file goes out");
        $this->assertTrue($largest <= WebSite::OUT_FILE_STEP,
            "no more than one piece is held at a time");
    }
    /**
     * A connection speaking HTTP/2 keeps to the HTTP/2 way of finishing
     * a write even while it carries a socket, since the rest of a large
     * reply is still to be sent on it. A connection upgraded from
     * HTTP/1.1 carries its socket alone and takes the socket way.
     */
    public function drainPathFollowsTheProtocolTestCase()
    {
        $this->assertTrue(!$this->uses_socket_drain->invoke($this->site,
            $this->connectionSpeaking('h2', true)),
            "a connection speaking HTTP/2 keeps to the HTTP/2 way");
        $this->assertTrue($this->uses_socket_drain->invoke($this->site,
            $this->connectionSpeaking('ws', true)),
            "a connection upgraded to a socket takes the socket way");
        $this->assertTrue(!$this->uses_socket_drain->invoke($this->site,
            $this->connectionSpeaking('h1', false)),
            "an ordinary connection takes neither");
        $this->assertTrue(!$this->uses_socket_drain->invoke($this->site,
            null),
            "a connection already taken down takes neither");
    }
}
X