Changelog
[v4.4.0] - 2026-06-05
- Requires
php-opcua/opcua-client^4.4 (usesTcpTransport::fromConnectedSocket()and the matchingManagesConnectionTrait::performConnect()skip — both added in core v4.4.0) - Requires
php-opcua/uanetstandard-test-suitev1.5.1+ for the integration suite (theTestServer/ReverseConnect/StartReverseConnect/StopReverseConnectMethod nodes used to trigger the server-side outbound dial); v1.5.1+ recommended for the readiness-gated healthcheck (see Integration suite hardening below)
Fixed — Integration suite hardening (CI)
- Factory end-to-end test now binds
0.0.0.0:0(was127.0.0.1:0), matching the accept/reject tests. In CI the server runs in a container and dials the listener viahost.docker.internal, whichhost-gatewaymaps to the Docker bridge gateway IP — a loopback-bound listener is unreachable from inside the container, so the inbound ReverseHello never arrived and the test hit the 20saccept()timeout. rcConnectTriggerClient()now retries the triggerconnect()for up to 15s, absorbing the transientBadServerHalted(0x800E0000) ServiceFault /ConnectionExceptiona freshly booted UA-.NETStandard server returns before itsServerInternalreaches the Running state. Belt-and-suspenders with the v1.5.1 test-suite healthcheck that now gatesdocker compose --waiton a real readiness marker. Locally the server is already running, so this race only surfaced in CI.
Tests & coverage
- New unit test —
ReverseConnectListenerread-timeout path: a peer that connects, sends only the 8-byte header, then stalls, exercising thestream_set_timeout()branch inreadExactly()(ReverseHelloParseException: Timeout while reading ReverseHello frame). Deterministic, loopback-only (~1s), no Docker. - Real coverage on PHP 8.5 in CI — the integration job now runs the full suite (unit + integration) on 8.5 instead of
--group=integrationonly, so the Clover/Codecov report reflects both the loopback unit branches and the real-socket integration branches. Other PHP versions stay integration-only (unit already runs in theunitjob). The only lines left uncovered are defensive syscall-failure guards (stream_socket_get_name()/stream_select()/stream_socket_accept()returningfalse, and the generic\Throwablerethrow inaccept()), unreachable without mocking system primitives.
First public release. Ships the OPC UA Reverse Connect (ReverseHello) listener as an optional extension of php-opcua/opcua-client — implementing the client-side half of OPC UA Part 6 §7.1.2.3. The core opcua-client only exposes the TcpTransport::fromConnectedSocket() seam; everything else (listener, parser, whitelist, bridge, events, exceptions) lives here under the PhpOpcua\Client\ExtReverseConnect\* namespace. Applications that do not need Reverse Connect take no extra dependency.
Added — Public API
ReverseConnectListener— binds a TCP server socket viastream_socket_server('tcp://<host>:<port>'), accepts inbound RHE frames bounded by a caller-suppliedaccept(float $timeoutSeconds)budget overstream_select(), validates them, and returns aReverseConnectSession. Constructor:(string $bindHost, int $bindPort, ReverseHelloValidator $validator, ?LoggerInterface $logger = null, ?EventDispatcherInterface $dispatcher = null, int $maxFrameSize = 65535). Public methods:listen(),accept(),close(),getBindAddress(),isListening(). Idempotentlisten()/close().ReverseHelloParser— pure decoder of theRHEwire frame (Part 6 §7.1.2.3). Staticparse(string $frame, int $maxFrameSize = self::DEFAULT_MAX_FRAME_SIZE): ReverseHelloMessage. Public constantsMIN_FRAME_SIZE = 16andDEFAULT_MAX_FRAME_SIZE = 65535. ReusesBinaryDecoder::readString()from the core for OPC UA String decoding; normalises the null string (length-1) to the empty string.ReverseHelloMessage— immutable readonly DTO with publicstring $serverUriandstring $endpointUrl.ReverseHelloValidator— whitelist + scheme check. Fail-secure by default: an empty whitelist refuses every message. Rejection rules in order: emptyServerUri,ServerUrinot in the whitelist (exact, case-sensitive — RFC 3986), emptyEndpointUrl,EndpointUrlnot starting withopc.tcp://. API:__construct(iterable $allowedServerUris),getAllowedServerUris(),ensureAccepted()(throws),isAccepted()(bool).ReverseConnectSession— immutable readonly value object holding the validatedserverUri,endpointUrl, and the livemixed $socket(stream resource).ReverseConnectClientFactory— bridge to the standardClientBuilder.buildClient(ReverseConnectSession $session, ?Closure $configure = null, ?float $readTimeout = null): Clientwraps the session's socket intoTcpTransport::fromConnectedSocket(...), callsClientBuilder::setTransport(), invokes the optional$configureclosure, and runsClientBuilder::connect($session->endpointUrl). The core'sManagesConnectionTrait::performConnect()detects the already-connected transport viaisConnected()and skips the redundant outboundconnect($host, $port).
Added — Events (PSR-14)
Three event classes, all final readonly, dispatched only when an EventDispatcherInterface is provided to the listener (zero overhead otherwise — events are not constructed at all):
ReverseHelloReceived(message)— dispatched after a successful frame decode, before the validator runs.ReverseConnectAccepted(message)— dispatched after the validator silently approves the message, beforeaccept()returns the session.ReverseConnectRejected(message, reason)— dispatched when the validator refuses a syntactically valid frame, just beforeReverseConnectRejectedExceptionis raised.
Added — Exceptions
Four classes rooted in RuntimeException:
ReverseConnectException— base class for every error from the package (bind failure,accept()called beforelisten(),stream_select()/stream_socket_accept()returningfalse, …).ReverseHelloParseException— wire-format / framing errors; the originalEncodingExceptionfrom the core is attached aspreviouswhen relevant.ReverseConnectRejectedException— carriespublic readonly ReverseHelloMessage $rejectedMessageso logs and event handlers can quote the original payload without re-parsing.ReverseConnectTimeoutException—accept()budget elapsed without an inbound connection.
Added — Tests
- 44 unit tests (
tests/Unit/) usingstream_socket_server()+stream_socket_accept()over loopback TCP (deliberately notstream_socket_pair(STREAM_PF_UNIX, …)) so the suite stays portable on Linux, macOS, and Windows. Coverage of: parser positive + every rejection rule (13 tests), validator including fail-secure default + case-sensitivity (12 tests), listener accept happy-path + reject + parse-fail + timeout + bind-error (15 tests), factory transport wiring +$configurecallback contract (4 tests). - 4 end-to-end integration tests (
tests/Integration/ReverseConnectE2ETest.php, groupintegration) againstuanetstandard-test-suitev1.4.0+: the PHP client connects normally toopc.tcp://localhost:4840, callsStartReverseConnect(host.docker.internal, <ephemeral-port>), accepts the inbound RHE on a0.0.0.0:0listener, validates the announcedServerUri = "urn:opcua:testserver:nodes", and builds a fully connectedClientvia the factory. Cleanup viaStopReverseConnect. - Shared test helper
tests/Unit/Helpers/InMemoryEventDispatcher.php— local copy of the core's PSR-14 test fixture.
Added — Docs & examples
- Full
docs/tree in theopcua-clientstyle (frontmattereyebrow/lede/see_also/prev/next):index.md,overview.md,getting-started/{installation,quick-start}.md,concepts/how-it-works.md,api/{listener,validator,factory,events}.md,recipes/docker-host-networking.md,reference/exceptions.md. Published at https://www.php-opcua.com/dev/components. - Worked example at
examples/exts/reverse-connect/listener.php— opens a listener on0.0.0.0:0, triggers the server viaStartReverseConnect, accepts the RHE, builds aClient, readsTestServer/DataTypes/Scalar/Int32Valuethrough the reverse channel, and tears down viaStopReverseConnect. Output verified end-to-end against the test-suite container. README.mdmodelled on the core README (badges, quick start, "See It in Action", "Why This Package?", ecosystem, testing, community).SECURITY.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,LICENSE, plus.github/withworkflows/tests.yml, three issue templates (bug_report,feature_request,question),pull_request_template.md, andcopilot-instructions.md.
Core-side requirement
This release pairs with php-opcua/opcua-client v4.4.0, which adds:
TcpTransport::fromConnectedSocket(mixed $socket, ?float $readTimeout = null): self- A skip in
ManagesConnectionTrait::performConnect()that bypassestransport->connect($host, $port)whentransport->isConnected() === true
No other change to the core is involved. See the opcua-client v4.4.0 entry for the matching upstream notes.