Skip to content

Release 4.1.2

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 12:52
· 3 commits to main since this release

Performance Improvements in src/

A collection of targeted speed improvements to the NNTP client's hot paths: socket I/O loops, response parsing, and article processing.

All 290 tests pass. PHPStan reports zero errors.


Changes

1. Replace preg_replace with str_replace in _sendArticle()

File: src/Protocol/Client.php

The NNTP dot-stuffing substitution ("\n." → "\n..") is a literal string replacement — no regex features are used. str_replace avoids regex compilation overhead entirely.

Before:

@fwrite($this->_socket, preg_replace("|\n\.|", "\n..", $article) . "\r\n.\r\n");
// ...
@fwrite($this->_socket, preg_replace("|\n\.|", "\n..", $header) . "\r\n" . preg_replace("|\n\.|", "\n..", $body) . "\r\n.\r\n");

After:

@fwrite($this->_socket, str_replace("\n.", "\n..", $article) . "\r\n.\r\n");
// ...
@fwrite($this->_socket, str_replace("\n.", "\n..", $header) . "\r\n" . str_replace("\n.", "\n..", $body) . "\r\n.\r\n");

2. Reduce redundant trimming in _getStatusResponse()

File: src/Protocol/Client.php

The response was being rtrim'd for logging, then ltrim'd for parsing, then rtrim'd again inside the substr. A single trim() replaces all three operations.

Before:

$this->_logger?->debug('S: ' . rtrim($response, "\r\n"));

$response = ltrim($response);

$this->_currentStatusResponse = [
    (int) substr($response, 0, 3),
    (string) rtrim(substr($response, 4)),
];

After:

$response = trim($response);

$this->_logger?->debug('S: ' . $response);

$this->_currentStatusResponse = [
    (int) substr($response, 0, 3),
    (string) substr($response, 4),
];

3. Throttle _clearOpensslErrors() in _getTextResponse()

File: src/Protocol/Client.php

Previously called _clearOpensslErrors() twice per line read (before and after fgets) inside the reception loop. For large multi-line responses over TLS (e.g., overview or article body fetches with thousands of lines), this called openssl_error_string() in a while loop on every single line — a significant overhead.

Now clears once before the loop and only on the error path.

Before:

while (!feof($this->_socket)) {
    if ($hasEncryption) {
        $this->_clearOpensslErrors();
    }
    $received = @fgets($this->_socket, 8192);
    if ($hasEncryption) {
        $this->_clearOpensslErrors();
    }

    if ($received === false) {
        $meta = stream_get_meta_data($this->_socket);
        // ...
    }

After:

// Clear any stale OpenSSL errors once before entering the loop
if ($hasEncryption) {
    $this->_clearOpensslErrors();
}

while (!feof($this->_socket)) {
    $received = @fgets($this->_socket, 8192);

    if ($received === false) {
        if ($hasEncryption) {
            $this->_clearOpensslErrors();
        }
        $meta = stream_get_meta_data($this->_socket);
        // ...
    }

4. Limit explode() in group-parsing methods

File: src/Protocol/Client.php

Added a limit parameter to explode() in _parseGroupLines() and cmdListActive() to stop splitting beyond the required number of fields. Avoids unnecessary array allocations, especially for large group lists.

Before:

// _parseGroupLines()
$arr = explode(' ', trim($line));

// cmdListActive()
$arr = explode(' ', trim($line));

After:

// _parseGroupLines()
$arr = explode(' ', trim($line), 4);

// cmdListActive()
$arr = explode(' ', trim($line), 4);

5. Disable PHP userspace read buffer on socket

File: src/Protocol/Client.php

Added stream_set_read_buffer($this->_socket, 0) after socket creation to eliminate PHP's internal read buffer, reducing double-buffering with the OS TCP buffer.

Before:

stream_set_timeout($this->_socket, $timeout);

After:

stream_set_timeout($this->_socket, $timeout);
stream_set_read_buffer($this->_socket, 0);

6. Fast-path overview parsing with array_combine

File: src/Client.php

When no "full" header fields exist in the overview format (the common case), the per-field strpos/substr loop is now skipped entirely in favor of a single array_combine() call. The slow path with header stripping is only used when actually needed.

Before:

foreach ($overview as $key => $article) {
    $mapped = [];
    for ($i = 0; $i < $fieldCount; $i++) {
        $value = $article[$i] ?? '';
        if (isset($fullIndices[$i])) {
            $pos = strpos($value, ':');
            $value = ltrim(substr($value, ($pos === false ? 0 : $pos + 1)), " \t");
        }
        $mapped[$fieldNames[$i]] = $value;
    }
    $overview[$key] = $mapped;
}

After:

if ($fullIndices === []) {
    // Fast path: no "full" header fields — use array_combine directly
    foreach ($overview as $key => $article) {
        $overview[$key] = array_combine(
            $fieldNames,
            \array_slice($article, 0, $fieldCount) + array_fill(0, $fieldCount, '')
        );
    }
} else {
    foreach ($overview as $key => $article) {
        $mapped = [];
        for ($i = 0; $i < $fieldCount; $i++) {
            $value = $article[$i] ?? '';
            if (isset($fullIndices[$i])) {
                $pos = strpos($value, ':');
                $value = ltrim(substr($value, ($pos === false ? 0 : $pos + 1)), " \t");
            }
            $mapped[$fieldNames[$i]] = $value;
        }
        $overview[$key] = $mapped;
    }
}

7. Replace explode + array_filter + array_values chain with preg_split

File: src/Client.php

In getReferences(), three chained function calls (plus a closure allocation per iteration) are replaced with a single preg_split() call using PREG_SPLIT_NO_EMPTY.

Before:

foreach ($references as $key => $val) {
    $references[$key] = array_values(
        array_filter(explode(' ', trim($val)), static fn(string $s): bool => $s !== '')
    );
}

After:

foreach ($references as $key => $val) {
    $references[$key] = preg_split('/\s+/', trim($val), -1, PREG_SPLIT_NO_EMPTY);
}

Summary

# Location Optimization Impact
1 Protocol/Client::_sendArticle() preg_replace → str_replace Eliminates regex compilation on every article post/transfer
2 Protocol/Client::_getStatusResponse() Single trim() instead of 3 separate trims Removes redundant string scans on every NNTP response
3 Protocol/Client::_getTextResponse() _clearOpensslErrors() moved out of per-line loop Eliminates 2× openssl_error_string() drain per line in TLS responses
4 Protocol/Client::_parseGroupLines() / cmdListActive() explode() with limit parameter Stops splitting beyond required fields on large group lists
5 Protocol/Client::connect() stream_set_read_buffer($socket, 0) Reduces double-buffering between PHP and OS TCP buffer
6 Client::getOverview() array_combine fast-path Skips per-field loop entirely when no full-header stripping needed
7 Client::getReferences() preg_split replaces 3-function chain One call instead of three; no closure allocation per iteration