Repository navigation
[v4.6.0] - 2026-09-17 Self-Healing
Added
-
Sessions survive reconnects and process restarts.
reconnect()— and so
auto-retry — opens a new secure channel and reactivates the existing session
on it with ActivateSession, instead of creating a new session, so
subscriptions and monitored items keep running;SessionReactivatedis
dispatched. If the server rejects the session, a new one is created. The
session knowledge is kept across failed reconnect attempts while the server
is unreachable.ClientBuilder::setReactivateSession(false)restores the
previous behaviour; an expired session (setRecreateExpiredSession()) always
gets a new one.Client::getSessionState()returns aSessionState
(endpoint, authentication token, server nonce, session timeout; JSON
serializable, restored withSessionState::fromArray()),Client::suspend()
closes the channel and socket without closing the session, and
ClientBuilder::resumeSession()reactivates a saved session on connect.
SessionService::decodeActivateSessionResponse()now returns the server
nonce, which signs the next activation. Against UA-.NETStandard, an anonymous
session resumed by a new client 15 s after a simulated crash delivers every
sampled value, with and without SignAndEncrypt; a subscription cannot be
transferred to a new anonymous session (BadUserAccessDenied), so
reactivation is the only way to keep it. -
Expired sessions are recreated. When a call fails with
BadSessionIdInvalid,BadSessionClosedorBadSessionNotActivated,
executeWithRetry()reconnects with a new session and repeats the call once,
independently ofsetAutoRetry(). The auto-retry documentation already
listed session invalidation as recoverable, but onlyConnectionException
was handled. On by default;ClientBuilder::setRecreateExpiredSession(false)
turns it off. Against UA-.NETStandard, a 10 s session idle for 30 s reads
again on a new session. -
Security token renewal can be turned off with
ClientBuilder::setRenewSecurityToken(false); it stays on by default.
ClientBuilderInterfacegains both setters and theiris…()getters. -
Configurable session timeout.
ClientBuilder::setSessionTimeout(float $milliseconds)(default120000, the value previously hard-coded in both
CreateSession encoders) andgetSessionTimeout();Client::getSessionTimeout()
andOpcUaClientInterface::getSessionTimeout()return the timeout the server
granted, which the CreateSession decoder previously discarded.
SessionService::encodeCreateSessionRequest()takes the requested timeout and
decodeCreateSessionResponse()returnsrevisedSessionTimeout. Against
UA-.NETStandard,1000is revised to10000and a10000session idle for
30 s fails the next call withBadSessionIdInvalid(the server checks for
expired sessions every 10 s).ClientBuilderInterface
gainssetSessionTimeout()/getSessionTimeout()andOpcUaClientInterface
gainsgetSessionTimeout(): custom implementations must add them. -
MonitoredItemResult::$selectClauseResults, an optional trailing
constructor parameter carried through the wire format. The CreateMonitoredItems
decoder discarded theEventFilterResult(ns=0;i=736,
ServiceTypeId::EVENT_FILTER_RESULT_ENCODING), so an event item rejected for
a bad select clause gave no hint of which clause failed. Against open62541,
['EventId', 'DoesNotExist', 'Severity']now reports[Good, BadNodeIdUnknown, Good]; UA-.NETStandard sends no per-clause results. -
PublishResult::$publishTimeandPublishResult::$acknowledgementResults,
added as optional trailing constructor parameters and carried through the
wire format. The Publish decoder read both and discarded them: an
acknowledgement the server rejected went unnoticed, andpublish()lacked
the publish timerepublish()already returned. Against UA-.NETStandard, a
valid acknowledgement reportsGood, an unknown sequence number
BadSequenceNumberUnknownand an unknown subscription
BadSubscriptionIdInvalid. -
SecureChannelRenewedevent (channelId,tokenId,revisedLifetime),
dispatched after each security token renewal. -
Data change filters. Items passed to
createMonitoredItems()and
modifyMonitoredItems()accept afilterkey —
['trigger' => int, 'deadbandType' => int, 'deadbandValue' => float]—
encoded as aDataChangeFilterExtensionObject
(ServiceTypeId::DATA_CHANGE_FILTER_ENCODING,ns=0;i=724). Omitted keys
default to the StatusValue trigger with no deadband. A Percent deadband on a
variable without anEURangeis rejected per item with
BadMonitoredItemFilterUnsupported. -
discardOldeston creation.createMonitoredItems()accepts
discardOldest(defaulttrue, as before); it could previously be set only
throughmodifyMonitoredItems(). -
MonitoredItemsBuilder:monitoringMode(),discardOldest()and
dataChangeFilter(), so the builder covers every item key. -
republishedon notification events.DataChangeReceived,
EventNotificationReceivedand the nine alarm events carry
bool $republished(defaultfalse, added as the last constructor
parameter).
Changed
- Browse raises the result's Bad status.
browse(),
browseWithContinuation(),browseNext()and, through them,browseAll()
andbrowseRecursive()discarded the StatusCode of the BrowseResult, so a
node that does not exist returned an empty array indistinguishable from a
node without children. A Bad result now raisesServiceExceptionwith that
status code (BadNodeIdUnknown,BadReferenceTypeIdInvalid,
BadContinuationPointInvalid, …); a rejected browse is not cached. Callers
that relied on an empty array for a missing node must catch it. - HistoryRead raises the result's Bad status.
historyReadRaw(),
historyReadProcessed()andhistoryReadAtTime()discarded the
StatusCode of the node's HistoryRead result, so a rejected read — an
unknown aggregate, an unsupported operation — returned an empty array
indistinguishable from "no data". A Bad result now raises
ServiceExceptionwith that status code. Callers that relied on an empty
array for a rejected read must catch it. republish()dispatches PSR-14 events, consistent withpublish():
DataChangeReceived,EventNotificationReceivedand the alarm events, with
republished: true. A retransmission can repeat a notificationpublish()
already delivered, so listeners that must not process a value twice check
the flag.PublishResponseReceivedandSubscriptionKeepAliveremain
publish()-only.
Fixed
-
Client::getAutoRetry()reported a retry that never happened. For a
connected client whoseautoRetrywas never set it returned1, while
executeWithRetry()appliesautoRetry ?? 0and does not retry. It now
returns0, the effective default;setAutoRetry()still enables retries.
Verified against UA-.NETStandard: withoutsetAutoRetry()a closed socket
raisesConnectionException, withsetAutoRetry(1)the next read reconnects. -
History reads stopped at the server's first page. The documentation said
continuation points were followed transparently, but the decoder discarded
them: against open62541, which returns at most 1 024 values per response,
historyReadRaw()of 1 500 values returned 1 024 without an error, whatever
numValuesPerNodewas.historyReadRaw(),historyReadProcessed()and
historyReadAtTime()now follow continuation points until the server is
done; withnumValuesPerNodeset, reading stops at that many values and the
pending continuation point is released. The HistoryRead encoders accept a
continuation point and the release flag, and
HistoryReadService::decodeHistoryReadResponseWithContinuation()returns
the values with the continuation point. -
The secure channel security token was never renewed. The client asked
for a one-hour token, discarded the lifetime the server granted and had no
renewal path, so every connection failed once the token expired: against
UA-.NETStandard with a 30 s token, the first request after expiry returned
0x807F0000and the server then closed the channel, losing session and
subscriptions. The client now schedules a renewal at 75% of the revised
lifetime and, on the first request after that point, sends an
OpenSecureChannelRenewon the open channel — with a fresh nonce and new
symmetric keys when security is active — then keeps using the same session.
A connection idle past the token lifetime renews on its next request.
SecureChannelRequest::encode()takes the request type and sequence number,
SecureChannel::createOpenSecureChannelMessage()a$renewflag, and
SessionService::setTokenId()updates the token of a channel without
security. -
Responses larger than one message chunk were truncated. The transport
returned a single chunk andunwrapResponse()treated it as the whole
message, while the Hello advertises no message size or chunk count limit, so
servers legitimately split any response above the negotiated buffer
(65 535 bytes) into chunks. The client decoded the first chunk only — a
one-hourhistoryReadRaw()failed withBuffer underflow— and left the
remaining chunks on the socket.unwrapResponse()now reads the chunks that
follow an intermediate (C) chunk and appends their bodies, in plain and
secured channels alike (each chunk is verified and decrypted on its own). An
abort chunk (A) raisesServiceExceptionwith the server's error and
reason; a chunk belonging to another request raisesProtocolException.
Verified against UA-.NETStandard: a 262 144-bytereadFile()in one call,
with no security, Sign and SignAndEncrypt. -
Responses were not matched to their request. A response left on the
socket — the tail of a truncated message, or the answer to a request whose
reply was never read — was taken as the reply to the next call, so the
connection kept returning other requests' data without an error. The client
now remembers the request ID issued last and discards responses to any other
request. Transports that own the secure channel (such as the HTTPS
transport, which pairs each response with its request) are not checked. -
The data change
filterwas silently ignored. The recipe
Subscribing to data changes documented a deadbandfilter, but both
encoders always wrote a null filter, so every change was reported. Verified
against UA-.NETStandard: with an Absolute deadband of 5 on a value of 100, a
write of 102 is now suppressed and a write of 110 reported. -
Six
StatusCodeconstants had the wrong value. Hand-written, they
held the value of a different code:BadNotReadable0x803E0000→
0x803A0000(the old value isBadNotFound),BadNoData0x80B10000→
0x809B0000(old valueBadNoDataAvailable),BadAggregateNotSupported
0x80D80000→0x80D50000(old valueBadBoundNotSupported) and
UncertainDataSubNormal0x40A30000→0x40A40000(the old value is not a
standard code). Comparisons against them never matched what servers send, and
the client-side aggregate calculators emitted the wrong codes.
BadFileHandleInvalidandBadFileNotOpenedare not standard codes: they
are deprecated and now alias the codes FileType methods return,
BadInvalidArgument(0x80AB0000, was0x80E70000,BadDataSetIdInvalid)
andBadInvalidState(0x80AF0000, was0x80E80000,
BadTransactionPending). Every value is checked by an integration test
against the code UA-.NETStandard itself returns. -
StatusCode::getName()returned hex for most codes. It knew only the
30 codes with a constant, so a server'sBadMonitoredItemFilterUnsupported
came back as0x80440000, and any code with InfoBits — a Good value with
Overflow, say — came back as hex too. It now names all 272 standard codes
(generated from the OPC Foundation's UA-NodesetStatusCode.csv) from the
upper 16 bits and appends the set low bits in brackets:StructureChanged,
SemanticsChangedand, under the DataValue InfoType, the limit, overflow and
historian flags (Good [LimitHigh, Overflow]); undefined bits are appended
as hex. The table references the constants by name. -
Event reference. Key fields listed properties the classes do not have
(acknowledger,shelved,oldSeverity/newSeverity); the monitored
item,SubscriptionTransferredandTriggeringConfiguredevents were
described as firing on Good only, while they fire for every result with its
statusCode; the alarm rows now describe the actual deduction rules. The
@seetags of the subscription events pointed to the removed
ManagesSubscriptionsTrait; they now point toSubscriptionModule. -
republish()now returns the retransmitted notifications.
SubscriptionService::decodeRepublishResponse()read the header of every
NotificationData entry and skipped its body, sonotificationswas always
an empty array even when the server retransmitted data. Publish and
Republish responses now share one decoder (DecodesNotificationDataTrait),
sorepublish()returns the sameDataChangeNotification/
EventNotificationobjects aspublish(). Entries without a body, with
an empty body or XML-encoded are skipped without desynchronising the
decoder, in both responses.
Tests
-
Session reactivation (
SessionReactivationTest, against UA-.NETStandard,
None and Basic256Sha256 SignAndEncrypt): a dropped connection reactivates
the same session and the subscription's counter values continue without a
gap; with reactivation off a new session is created; a client resuming the
session of a crashed one after 15 s receives every missed value (republishing
the unacknowledged sequence numbers); a suspended session resumes; an unknown
saved session falls back to a new one. The reactivation cases fail when the
pending session is ignored. Unit:SessionStateJSON round trip and
validation, builder andMockClientoptions. -
Auto-retry default (
AutoRetryDefaultTest, against UA-.NETStandard): a
client withoutsetAutoRetry()reports0and throwsConnectionException
after its socket is closed; withsetAutoRetry(1)the next read reconnects.
Unit: noRetryAttemptby default, one withautoRetry1. The existing
tests that expectedgetAutoRetry()to return1after connecting now
expect0. -
Session recreation and token renewal flags (against UA-.NETStandard): an
expired 10 s session reads again on a new session withClientReconnecting,
and with recreation off the call fails withBadSessionIdInvalid; with
renewal off, reads on the 30 s token server fail once the token expires. The
recreation test fails without the change. Unit: builder andMockClient
defaults and setters, no OPN sent when renewal is off. -
Session timeout (
SessionTimeoutTest, against UA-.NETStandard): the default
is granted at 120 000 ms, 15 000 ms at 15 000 and 1 000 ms raised to
10 000; after 30 s idle the 10 s session fails withBadSessionIdInvalid
while the default one still reads. Unit: builder default and setter, the
requested value in the CreateSession request,MockClient. -
Event filter results (
EventFilterResultTest, against open62541): valid
clauses give a Good item without per-clause results; a mixed and a fully
invalid selection report the status of every clause. Unit: the
EventFilterResultis decoded and survives the wire round-trip. -
Publish (
PublishResultTest, against UA-.NETStandard):publishTimeis
within seconds of now, and three acknowledgements reportGood,
BadSequenceNumberUnknownandBadSubscriptionIdInvalid. Unit: both fields
are decoded and survive the wire round-trip. -
History pagination (
HistoryContinuationTest, against open62541-historizing):
the server's first page holds 1 024 of 1 500 inserted values plus a
continuation point;historyReadRaw()returns all 1 500 with a second
HistoryRead that carries it, andnumValuesPerNode1 100 and 5 return exactly
those values (the latter releasing the continuation point), each in two
requests counted on the wire. Unit: the
continuation point and release flag are encoded and decoded, pages are
followed for raw, processed and at-time reads, and the continuation point is
released whennumValuesPerNodeis reached. -
Token renewal (
SecureChannelRenewalTest, against the test suite's
opcua-short-token-lifetimeserver with a 30 s token): in None, Sign and
SignAndEncrypt, 45 s of reads succeed with at least one renewal and the same
session; a subscription created before the renewal keeps delivering data
changes after it, on the same session; after 40 s idle the next read renews
and succeeds. All fail without the renewal. Unit: the Renew request encoding, renewal once due with
the new token on the next request, no renewal before. -
Browse (
BrowseStatusTest, against UA-.NETStandard): a missing node raises
BadNodeIdUnknownfrombrowse(),browseAll()andbrowseRecursive(),
also on a repeated call (not cached); an unknown reference type raises
BadReferenceTypeIdInvalid; an unknown continuation point raises
BadContinuationPointInvalid; a node without children returns[]. Unit:
a Bad BrowseResult raises its status, an Uncertain one keeps decoding. -
Unit (
ClientChunkedResponseTest): chunked responses are assembled; stale
responses, single or chunked, are discarded; abort chunks, ERR messages
between chunks and chunks of another request are raised; transports with an
external secure channel skip the request check. The unit mock transports echo
the request ID of the last request, as a server does. Integration, against
UA-.NETStandard with no security, Sign and SignAndEncrypt
(ChunkedMessageTest): a 262 144-bytereadFile()in one call, and a read
after an abandoned request returns its own value. -
Unit: a Republish response carrying DataChange, StatusChange and
EventNotificationList entries decodes into typed objects; bodyless, empty
and XML-encoded entries are skipped. Integration, against UA-.NETStandard:
for every unacknowledged sequence number,republish()returns the same
client handles and valuespublish()delivered. -
Unit: the DataChangeFilter ExtensionObject is encoded for create and modify,
with defaults for missing keys;discardOldestis encoded on creation; the
builder sets mode, discard policy and filter;republish()dispatches
data change, event and alarm events flaggedrepublishedand no
publish-only events, whilepublish()events stay unflagged. Integration,
against UA-.NETStandard: an Absolute deadband suppresses small changes and is
removed throughmodifyMonitoredItems(); a Percent deadband on a variable
without anEURangeis rejected. The new tests fail against the previous
encoder and module. -
Integration, against UA-.NETStandard (
StatusCodeConstantsTest): each
corrected constant equals the code the stack returns —BadNotReadableon a
write-only variable,BadNoDataandUncertainDataSubNormalfrom the SDK's
aggregate calculators,BadAggregateNotSupportedfor an unknown aggregate,
andBadFileHandleInvalid/BadFileNotOpenedfrom FileTypeRead.
Requires the test suite's new processed-history support and
HistoricalWithBadSamplesnode. Unit:getName()names every standard
code and appends InfoBits.