Skip to content

Releases: DariusIII/Net_NNTP

Release 4.1.3

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 14:57

Release Notes

Performance: Built-in function call optimisation (PHP 8.5)

Background

PHP 8.5's Zend compiler (zend_compile.c line 4975)
contains a function — zend_try_compile_special_func_ex — that compiles certain built-in calls
directly to single VM opcodes, completely bypassing the normal function-call overhead
(no frame push/pop, no argument marshalling, no symbol table lookup):

Function(s) Opcode emitted
strlen ZEND_STRLEN
is_null, is_bool, is_int, is_string, is_array, is_object, is_resource ZEND_TYPE_CHECK
count / sizeof ZEND_COUNT
in_array ZEND_IN_ARRAY
array_slice ZEND_ARRAY_SLICE
array_key_exists ZEND_ARRAY_KEY_EXISTS
intval, strval, boolval, floatval / doubleval ZEND_CAST
sprintf ZEND_FAST_CONCAT-like
chr, ord ZEND_CHR / ZEND_ORD
get_class, get_called_class, gettype ZEND_GET_CLASS / ZEND_GET_TYPE

The catch: this intrinsification only fires when the call is resolved at compile time.
Inside a namespace (e.g. DariusIII\NetNntp\Protocol), an unqualified call like trim() forces
the engine to check at runtime whether DariusIII\NetNntp\Protocol\trim exists before falling back
to the global. Prefixing with \ eliminates that runtime lookup and allows the compiler to
intrinsify or use the faster frameless call path.


Changes

src/Protocol/Client.php

  • Added \ prefix to all unqualified built-in function calls, enabling compile-time resolution
    and opcode intrinsification:
    openssl_error_string, strlen, strpbrk, fwrite, fgets, stream_get_meta_data,
    trim, substr, feof, str_ends_with, str_starts_with, str_replace, explode,
    reset, next, stream_socket_client, stream_set_timeout, stream_set_read_buffer,
    fclose, stream_socket_enable_crypto, ltrim, gmdate, implode, strcasecmp,
    is_numeric, is_array, is_resource, is_string.

  • Removed redundant || strlen($line) < 2 branch in _getTextResponse().
    str_ends_with($line, "\r\n") returning true already guarantees strlen ≥ 2, so the second
    operand was dead code that emitted a ZEND_STRLEN opcode on every iteration of the hot
    receive loop for no reason.

  • Removed redundant (string) cast on substr($response, 4) in _getStatusResponse().
    substr() always returns string when given a string input, so the ZEND_CAST opcode
    was emitted unnecessarily.

src/Client.php

  • Added \ prefix to all unqualified built-in function calls:
    implode, strtotime, substr, array_merge, array_keys, array_values,
    array_combine, array_slice, array_fill, array_splice, strpos, ltrim,
    ctype_digit, str_starts_with, str_ends_with, reset, preg_split, trim,
    is_int, is_array, is_string, count.

Other housekeeping

  • Removed legacy package.xml (PEAR descriptor, superseded entirely by composer.json).

Release 4.1.2

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 12:52

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

Release 4.1.1

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 11:43

Full Changelog: 4.1.0...4.1.1

Release 4.1.0

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 11:26

Release v4.1.0

Namespace rename:

  • Renamed root namespace from Net\NNTP to DariusIII\NetNntp:
    • Net\NNTP\Client → DariusIII\NetNntp\Client
    • Net\NNTP\Error → DariusIII\NetNntp\Error
    • Net\NNTP\Protocol\Client → DariusIII\NetNntp\Protocol\Client
    • Net\NNTP\Protocol\ResponseCode → DariusIII\NetNntp\Protocol\ResponseCode
  • Updated composer.json PSR-4 autoload to "DariusIII\\NetNntp\\": "src/"
  • Added autoload-dev PSR-4 entry for test namespace "DariusIII\\NetNntp\\Tests\\": "tests/"
  • Updated all source files, tests, docs, and examples to use new namespace

Removed all backward-compatible shims:

  • Deleted src/Protocol/Responsecode.php — the define() constants shim file
    • All NET_NNTP_PROTOCOL_RESPONSECODE_* global constants are no longer defined
    • Use the DariusIII\NetNntp\Protocol\ResponseCode enum instead
  • Removed files autoload entry from composer.json (no longer needed)
  • Removed all deprecated API v1.0 and v1.1 methods from Client:
    • quit() — use disconnect()
    • isConnected() — was deprecated wrapper
    • getArticleRaw() — use getArticle()
    • getHeaderRaw() — use getHeader()
    • getBodyRaw() — use getBody()
    • getNewNews() — use getNewArticles()
    • getReferencesOverview() — use getReferences()
  • Removed v1.0 connect shim (passing port as second arg) from both Client and Protocol\Client
  • Removed v1.1 class-name-as-string shim from getArticle(), getHeader(), getBody() — $implode is now strictly bool
  • Removed v1.0 multi-argument shim from post() — parameter is now string|array
  • Removed v1.0 two-argument shim from getOverview()

API tightening:

  • getNewGroups() / getNewArticles() $time parameter: mixed → int|string
  • _resolveTimestamp() now throws \InvalidArgumentException instead of returning Error
  • post() parameter now typed string|array (use mail() for the old multi-arg posting style)
  • Removed @package_version@ / @package_state@ PEAR placeholders from X-poster mail header

Tests:

  • Added testDeprecatedMethodsRemoved() — verifies all 7 removed methods no longer exist
  • Added testLegacyConstantsNoLongerDefined() — verifies global constants shim is gone
  • Added type-assertion tests for cleaned-up method signatures ($implode is bool, post() is string|array)
  • Removed all legacy constant shim tests (47 removed)
  • Removed deprecated method existence tests
  • Test suite: 290 tests, 441 assertions

Bugfix release

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 10:48

Full Changelog: 4.0.0...4.0.0.1

Version 4.0.0

Choose a tag to compare

@DariusIII DariusIII released this 24 Feb 10:43

Release v4.0.0

Project structure & namespaces:

  • Modernized project to PSR-4 standard — source files moved from NNTP/ to src/
  • Added PHP namespaces: Net\NNTP, Net\NNTP\Protocol
  • Renamed classes from PEAR underscore style to namespaced short names:
    • Net_NNTP_Client → Net\NNTP\Client
    • Net_NNTP_Error → Net\NNTP\Error
    • Net_NNTP_Protocol_Client → Net\NNTP\Protocol\Client
  • Updated composer.json autoload from classmap to PSR-4 ("Net\\NNTP\\": "src/")
  • Added files autoload entry for Responsecode.php global constants
  • Removed all require_once includes from source files (now handled by Composer autoload)
  • Removed old NNTP/ directory

PEAR removal:

  • Removed all PEAR dependencies, code, and references
  • Removed PEAR_LOG_DEBUG constant and all _isMasked() calls from Protocol\Client
  • Replaced all PEAR::isError() calls with \Net\NNTP\Error::isError() throughout
  • Removed require_once "Log.php" and require_once "PEAR.php" from demo files
  • Removed grabPearErrors() / PEAR::setErrorHandling() from demo
  • Removed PEAR_Error references from Error class
  • Updated X-poster mail header: removed PEAR:: prefix
  • Replaced all @link http://pear.php.net/package/Net_NNTP with GitHub URL

PSR-3 logging:

  • Added psr/log as a dependency — the library now accepts any PSR-3 compatible logger
  • Type-hinted $_logger property and setLogger() parameter with Psr\Log\LoggerInterface
  • Made setLogger() public (was protected)
  • Removed deprecated setDebug() method from Protocol\Client
  • Updated demo Logger class to extend Psr\Log\AbstractLogger

Configuration:

  • Added vlucas/phpdotenv as a dependency for demo configuration
  • Replaced hardcoded config.inc.php with .env-based configuration using phpdotenv
  • Added .env.example with all available settings and documented defaults
  • Added .env to .gitignore

Test suite:

  • Added comprehensive PHPUnit 13 test suite (117 tests, 253 assertions):
    • tests/Unit/ErrorTest.php — Error class construction, isError(), toString
    • tests/Unit/ClientTest.php — Client instantiation, PSR-3 logger, public API surface
    • tests/Unit/ProtocolClientTest.php — Protocol layer, throwError, _isConnected, _clearOpensslErrors
    • tests/Unit/ResponsecodeTest.php — All 48 NNTP response code constants
    • tests/Unit/AutoloadTest.php — PSR-4 autoloading, namespaces, dependency wiring
    • tests/Unit/ConfigTest.php — .env.example completeness, phpdotenv integration
    • tests/Integration/ServerConnectionTest.php — Live server: connect, groups, articles, overview, disconnect
  • Updated phpunit.xml with separate Unit and Integration test suites
  • Updated demo and phpdoc examples to use namespaced classes

Enums & code optimization:

  • Created Net\NNTP\Protocol\ResponseCode int-backed enum with all 53 NNTP response codes
    • Each case includes a description() method returning the RFC description
    • Supports ResponseCode::from(int) and ResponseCode::tryFrom(int) natively
    • Added 6 new codes not previously defined: DisconnectingRequested (205), DisconnectingForced (400), TlsContinue (382), TlsRefused (580), XgtitleFollows (282), XgtitleUnavailable (481)
  • Converted Responsecode.php to a backwards-compatible shim — all define() constants now delegate to ResponseCode::CASE->value
  • Replaced all NET_NNTP_PROTOCOL_RESPONSECODE_* references in Protocol\Client with ResponseCode::CASE->value
  • Replaced all bare integer response codes (205, 382, 400, 502, 503, etc.) with enum references
  • Converted all switch blocks to match expressions throughout Protocol\Client and Client
  • Removed all unreachable break statements after return (~50+ instances)
  • Removed dead code: if (false) { ... } block, die() calls, unused namespace-level constants
  • Removed all vim modelines, {{{ }}} fold markers, and "Local variables" blocks from all source files
  • Added declare(strict_types=1) to all source files
  • Applied constructor property promotion with readonly to Error class
  • Refactored duplicated parsing logic in Protocol\Client into private helper methods:
    _parseGroupSelected(), _parseArticlePointer(), _fetchTextAndLog(), _parseGroupLines(),
    _parseOverviewResponse(), _parseKeyValueResponse(), _parseOverviewFormat(),
    _parseNewsgroupDescriptions(), _logAndReturn(), _handleTlsNegotiation()
  • Extracted _resolveTimestamp() helper in Client to deduplicate time parsing
  • Used str_ends_with() / str_starts_with() instead of substr() comparisons
  • Used null coalescing assignment (??=) throughout
  • Used nullsafe operator (?->) consistently for logger calls
  • Used union types for _sendArticle(string|array) parameter
  • Expanded test suite to 335 tests / 526 assertions (was 117 / 253):
    • Added enum tests: case values, from(), tryFrom(), description(), int-backed verification
    • Added legacy constant shim verification
    • Added ResponseCode autoload and namespace tests

Static analysis:

  • Added PHPStan (phpstan/phpstan) as a dev dependency
  • Created phpstan.neon configuration at level 8 (strict)
  • All source files pass PHPStan level 8 with zero errors
  • Added proper type annotations (PHPDoc @return, @param) to all methods throughout Protocol\Client and Client
  • Typed all deprecated method parameters and return types (getArticleRaw(), getHeaderRaw(), etc.)
  • Changed getArticle(), getHeader(), getBody() $implode parameter from bool to mixed for v1.1 API backward compatibility
  • Added @var PHPDoc annotations to all class properties
  • Added .phpstan-cache/ to .gitignore
  • Added composer scripts: composer analyse, composer test, composer check

Release 3.0.2

Choose a tag to compare

@DariusIII DariusIII released this 19 Feb 14:25
66ed30f

What's Changed

  • Optimize NNTP hot paths, fix command-length guard, and add XOVER-only integration benchmark by @CicerBro in #4

Full Changelog: 3.0.1...3.0.2

3.0.1

Choose a tag to compare

@DariusIII DariusIII released this 02 Feb 09:00

Full Changelog: 3.0...3.0.1

Drop pear dependency

Choose a tag to compare

@DariusIII DariusIII released this 02 Feb 08:48

Full Changelog: 2.1...3.0

2.1 - PHP 8.5 support

Choose a tag to compare

@DariusIII DariusIII released this 26 Jan 12:16

What's Changed

  • Fix syntax errors in switch cases for range checks by @CicerBro in #3

New Contributors

Full Changelog: 2.0.1.1...2.1