Repository navigation
Release 4.1.2
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 |