| eyebrow | Docs · ManagedClient | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| lede | Sessions are keyed by endpoint URL plus a sanitised configuration. Match the key, reuse the session. Miss any field, get a fresh handshake. The sanitisation rules matter. | |||||||||||||||
| see_also |
|
|||||||||||||||
| prev |
|
|||||||||||||||
| next |
|
SessionStore::findByEndpointAndConfig() is the function that
decides whether a connect() call reuses an existing session or
opens a fresh one. The match is exact: two (endpointUrl, sanitized config) tuples either match in every field, or they don't.
Not every field on the open command's config payload
participates in the keying. SessionConfig::sanitized() produces
the keying view:
| Field | Part of key? | Why |
|---|---|---|
securityPolicy |
yes | Different policy = different OPC UA session, period |
securityMode |
yes | Same |
username |
yes | Different identity = different session |
password |
NO | Nulled by SessionConfig::sanitized() before keying |
clientCertPath |
yes | Different cert path = different identity |
clientKeyPath |
NO | Nulled by sanitized() |
caCertPath |
NO | Nulled by sanitized() |
userCertPath |
yes | Same |
userKeyPath |
NO | Nulled by sanitized() |
opcuaTimeout |
yes | Different per-call timeout = different client config |
autoRetry |
yes | Different retry policy = different behavioural surface |
batchSize |
yes | Same |
defaultBrowseMaxDepth |
yes | Same |
autoDetectWriteType |
yes | Same |
readMetadataCache |
yes | Same |
trustStorePath |
yes | Different trust store = different validation |
trustPolicy |
yes | Same |
autoAccept, autoAcceptForce |
yes | Same |
The endpointUrl is always part of the key.
Secret values are stripped before keying.
password,clientKeyPath,caCertPath, anduserKeyPathare nulled bySessionConfig::sanitized()before the key tuple is built. Twoopencalls with the sameusernamebut differentpasswordvalues therefore match the same daemon-side session and the second caller silently reuses the first caller's credentials. This is a deliberate trade-off (secrets would otherwise sit in memory as cache-key strings) but it has security implications — rotateusername(which is in the key) whenever credentials change, not justpassword.
What is not part of the key:
- The application-level fields the daemon ignores anyway (a custom
descriptionfield passed by a wrapper, for instance). - The IPC
authToken— that is daemon-level authentication, not session-level identity.
Two separate sanitisations operate on the config:
SessionConfig::sanitized()— stripspassword,clientKeyPath,caCertPath,userKeyPath(sets them tonull) before computing the session-lookup key.usernameis preserved because it is part of session identity.CommandHandler::sanitizeConfig()— stripsusername,password,clientKeyPath,userKeyPath,caCertPathfrom theconfigreturned by thelistIPC command. See Daemon · Security hardening.
The keying happens in-memory and never reaches the wire; the list-side redaction protects the introspection surface. Both work together, but they strip slightly different field sets — the table above shows which fields end up in the lookup key.
$a = (new ManagedClient('/tmp/opcua-session-manager.sock'))
->setSecurityPolicy(SecurityPolicy::Basic256Sha256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setUserCredentials('integrations', getenv('PASS'));
$a->connect('opc.tcp://plc.local:4840');
// First connect — fresh session
assert($a->wasSessionReused() === false);
$b = (new ManagedClient('/tmp/opcua-session-manager.sock'))
->setSecurityPolicy(SecurityPolicy::Basic256Sha256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setUserCredentials('integrations', getenv('PASS')); // same
$b->connect('opc.tcp://plc.local:4840');
// Identical config — session reused
assert($b->wasSessionReused() === true);
assert($a->getSessionId() === $b->getSessionId());
$c = (new ManagedClient('/tmp/opcua-session-manager.sock'))
->setSecurityPolicy(SecurityPolicy::Basic256Sha256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setUserCredentials('different-user', getenv('PASS2'));
$c->connect('opc.tcp://plc.local:4840');
// Different username — different session
assert($c->wasSessionReused() === false);
assert($a->getSessionId() !== $c->getSessionId());Two clients that differ only in setTimeout(10.0) vs setTimeout(30.0)
get two distinct daemon-side sessions. Each session is opened
with the per-call OPC UA timeout the caller asked for.
If your application has one config object passing through multiple factories, make sure they all produce the same timeout value — otherwise you fragment the session pool.
setTrustStorePath('/var/lib/opcua/trust') and
setTrustStorePath('/var/lib/opcua/trust/') (trailing slash) are
different keys as far as the daemon is concerned. Canonicalise
your paths before passing them in.
The autoAcceptForce flag is keyed independently of autoAccept.
A boot-time autoAccept(true, force: false) session is distinct
from an admin tool's autoAccept(true, force: true) session. This
is intentional — you want operator overrides to be a distinct
session.
To get the highest cache and session reuse rate across your fleet:
Centralise the ManagedClient factory in one place — a Laravel service binding, a Symfony factory, a single module that all consumers call. The factory builds the client with a frozen, canonical configuration. Every call site gets the same configuration, so every call site reuses the same daemon-side session.
Don't build the ManagedClient inline at every call site with slight variations in setters. Each variation is a new session on the daemon; on a busy fleet you end up with hundreds of "almost the same" sessions instead of one shared one.
The list IPC command returns every active session with its
endpoint, last-used timestamp, and (redacted) config. Drive it via
SocketConnection:
use PhpOpcua\SessionManager\Client\SocketConnection;
$response = SocketConnection::send('/tmp/opcua-session-manager.sock', [
'command' => 'list',
]);
foreach ($response['data']['sessions'] as $sess) {
printf("%s %s lastUsed=%s\n",
$sess['id'],
$sess['endpointUrl'],
date('c', (int) $sess['lastUsed']),
);
}For raw debugging, see Recipes · Debugging with netcat.
Sessions are reclaimed by the cleanup loop when they have been
idle longer than --timeout (default 600 s). "Idle" means: no IPC
command has referenced the session in that window. A live
subscription that the daemon publish loop touches counts as
activity — sessions with active subscriptions never expire.
When an expired session is reclaimed:
- The daemon sends
CloseSession+CloseSecureChannelto the server. - The session is removed from the store.
- The next
connect()with that config opens a fresh session.
The application does not get notified of the expiration. The next operation simply pays the handshake cost.