Skip to content

Fix reverse connect hold time when several Servers share one listener - #4342

Merged
marcschier merged 3 commits into
masterfrom
romanett/fix-reverse-connect-hold-time-master
Aug 30, 2026
Merged

Fix reverse connect hold time when several Servers share one listener#4342
marcschier merged 3 commits into
masterfrom
romanett/fix-reverse-connect-hold-time-master

Conversation

@romanett

Copy link
Copy Markdown
Contributor

Description

Fixes the reverse connect problem reported in #3985 on master. #4009 and #4059 addressed the listener startup diagnostics and the async lifecycle, but the actual hold time defect described in mrsuciu's comment on the issue survived both refactors unchanged.

Symptom: a Client which uses one reverse connect listener port for several Servers connects to the first Server, and every following WaitForConnectionAsync times out. A separate listener port per Server was the only workaround.

Root cause

  • Servers which send ReverseHello before the application registered a waiting connection for them are held in OnConnectionWaitingAsync for HoldTime.
  • All held connections share a single CancellationTokenSource, so RegisterWaitingConnection/WaitForConnectionAsync wakes up every held connection, not only the one it was registered for.
  • The hold loop was while (!matched) { ... break; } with an unconditional break, so each woken connection got exactly one re-match attempt. The connections which did not match the new registration fell out of the loop unaccepted, although most of their own hold time was left.
  • An unaccepted connection is force faulted by the listener (TcpReverseConnectChannel, "The reverse connection was rejected by the client"), so that Server is dropped and only returns on its next ReverseHello interval, long after the Client timed out waiting for it.

Separate ports avoid the shared wakeup path, which is exactly why that workaround succeeds.

Changes

  • OnConnectionWaitingAsync re-arms the hold after a wakeup: it re-matches, and if the registration which caused the wakeup was for another Server the connection keeps waiting for the remainder of its own hold time. The loop is still bounded by HoldTime, so a Server which is never registered for is rejected exactly as before.
  • New IsCallbackDrainPending() check before the hold is re-armed. DrainConnectionCallbacksAsync cancels the hold token without renewing it, so a re-armed hold would spin on the already cancelled token until the hold time expires and would block the drain until then. With the check a held callback releases its transport immediately, which keeps the existing StopDrainsHeldConnectionCallbackBeforeListenerClose and DisposeDrainsHeldConnectionCallbackBeforeListenerDisposal guarantees intact.
  • docs/ReverseConnect.md: document the HoldTime semantics in the "Sharing a listener across multiple Servers" section.

No public API change.

Validation

  • New HeldConnectionSurvivesRegistrationForAnotherServer test in ReverseConnectManagerLifecycleTests drives two held callbacks through InvokeConnectionWaitingForTest and proves the second Server keeps its connection when the first Server is registered. Verified that it fails against the previous behaviour with "the reverse connection of the second Server was released before its hold time expired".
  • FullyQualifiedName~ReverseConnect (183 tests, including ReverseConnectManagerTests, ReverseConnectManagerLifecycleTests and ReverseConnectHostTests): all pass on net10.0 and net48.

Related Issues

Checklist

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation.
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed.
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings.
  • I have addressed all PR feedback received.

🤖 Generated with Claude Code

All reverse connections which are held by the ReverseConnectManager share
a single cancellation token source, so registering a waiting connection
wakes up every held connection. The hold loop stopped after a single
re-match attempt, which rejected the connections of all other Servers even
though most of their own hold time was left. The listener then force
faulted those sockets and WaitForConnectionAsync for those Servers timed
out, which is why a separate listener port per Server was needed.

The hold loop now re-arms after a wakeup and keeps the connection for the
remainder of its own hold time when the registration which caused the
wakeup was for another Server.

A committed Stop/Dispose cancels the hold token without renewing it, so
a held callback also checks the callback drain before it re-arms the hold.
Without that check it would spin on the already cancelled token until the
hold time expires and block the drain until then.

Relates to #3985

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes reverse-connect hold-time behavior when multiple Servers share a single listener by ensuring held callbacks re-check registrations after wakeups (instead of dropping the connection after a single wake). This addresses the multi-server timeout symptom reported in #3985 and preserves the existing stop/dispose drain guarantees.

Changes:

  • Rework ReverseConnectManager.OnConnectionWaitingAsync hold loop to re-arm waiting after unrelated registrations wake all held callbacks, and add a drain-pending guard.
  • Add regression test covering two held callbacks where registering/waiting for one Server must not release the other.
  • Document HoldTime semantics for shared-listener scenarios in docs/ReverseConnect.md.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/Opc.Ua.Client/ReverseConnectManager.cs Fix hold-loop behavior to keep unmatched reverse connections held for their remaining hold time; add drain-pending detection.
tests/Opc.Ua.Client.Tests/ClientBuilder/ReverseConnectManagerLifecycleTests.cs Add regression test proving a held connection survives a registration for a different Server.
docs/ReverseConnect.md Document HoldTime semantics and stop/dispose behavior for held reverse connections.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Opc.Ua.Client/ReverseConnectManager.cs
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Code coverage

Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.04% (243539/283042 lines) >= 70.00%
✅ Project branch rate 75.42% >= 60.00%
✅ Patch coverage 79.31% (23/29 changed lines) >= 60.00% (<= 100 changed lines, advisory)
ℹ️ Baseline delta (advisory) +12.44 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.Client/ReverseConnectManager.cs: 4587, 4589, 4590, 4591, 4592, 4595

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 85.43%, branch 74.89%.

@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.86207% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.20%. Comparing base (90a85ed) to head (2dafc90).

Files with missing lines Patch % Lines
src/Opc.Ua.Client/ReverseConnectManager.cs 75.86% 5 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4342      +/-   ##
==========================================
- Coverage   80.21%   80.20%   -0.01%     
==========================================
  Files        2014     2014              
  Lines      275050   275058       +8     
  Branches    47852    47854       +2     
==========================================
- Hits       220625   220615      -10     
- Misses      37507    37516       +9     
- Partials    16918    16927       +9     
Flag Coverage Δ
actions 80.20% <75.86%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/Opc.Ua.Client/ReverseConnectManager.cs 85.65% <75.86%> (+<0.01%) ⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@marcschier marcschier added the ready Ready to merge once CI Passes label Aug 30, 2026
@marcschier
marcschier enabled auto-merge (squash) August 30, 2026 15:01
marcschier pushed a commit that referenced this pull request Aug 30, 2026
…#4341)

# Description

Fixes the reverse connect problem reported in #3985: a Client which uses
one reverse connect listener port for several Servers connects to the
first Server, and every following `WaitForConnectionAsync` times out. A
separate listener port per Server was the only workaround.

## Root cause

Confirming the analysis in [mrsuciu's comment on
#3985](#3985):

- Servers which send `ReverseHello` before the application registered a
waiting connection for them are held in `OnConnectionWaitingAsync` for
`HoldTime`.
- All held connections share a single `CancellationTokenSource`, so
`RegisterWaitingConnection` wakes up **every** held connection, not only
the one it was registered for.
- The hold loop was `while (!matched) { ... break; }` with an
unconditional `break`, so each woken connection got exactly one re-match
attempt. The connections which did not match the new registration fell
out of the loop unaccepted, although most of their own hold time was
left.
- An unaccepted connection is force faulted by the listener
(`TcpReverseConnectChannel`, "The reverse connection was rejected by the
client"), so that Server is dropped and only returns on its next
`ReverseHello` interval, long after the Client timed out waiting for it.

Separate ports avoid the shared wakeup path, which is exactly why that
workaround succeeds.

## Changes

- `ReverseConnectManager.OnConnectionWaitingAsync` re-arms the hold
after a wakeup: it re-matches, and if the registration which caused the
wakeup was for another Server the connection keeps waiting for the
remainder of its own hold time. The loop is still bounded by `HoldTime`,
so a Server which is never registered for is rejected exactly as before.
- A shutdown flag set by `StopService`/`Dispose` (cleared by
`StartService`) releases held connections immediately during teardown,
instead of parking them until the hold time expires. `Dispose` now also
cancels the token source before disposing it, so a held callback can no
longer read a disposed token.
- `Docs/ReverseConnect.md`: new "Sharing a listener across multiple
Servers" section documenting that the listener port staying in
`LISTENING` is expected, that one shared `ReverseConnectManager` should
serve all Servers on the same Client Url, and the
`HoldTime`/`WaitTimeout` semantics.

No public API, signature or serialization change.

## Validation

- New `ReverseConnectManagerUnitTests` (3 tests): the two Server
regression, hold time expiry still rejecting, and dispose releasing a
held connection. They drive the callback through an internal test hook,
so no listener or socket is involved. Verified that the regression test
fails against the previous behaviour with "The reverse connection of the
second server was released before its hold time expired."
- New tests pass on net10.0 and net48.
- Full reverse connect suite (`FullyQualifiedName~ReverseConnect`,
including the existing `ReverseConnectTest` server fixture): 33/33 pass
on net10.0.

## Related Issues

- Relates to #3985.
- `master` carries the identical defect: the same `break` survived #4009
and #4059 and is still present in
`src/Opc.Ua.Client/ReverseConnectManager.cs`. The equivalent fix for
`master` is #4342 (it uses `TimeProvider` and breaks on a pending
callback drain instead of a new flag), so the issue is intentionally not
auto closed here.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@marcschier
marcschier merged commit 1f0125c into master Aug 30, 2026
233 of 274 checks passed
@romanett
romanett deleted the romanett/fix-reverse-connect-hold-time-master branch September 4, 2026 04:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready Ready to merge once CI Passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reverse Connect listener port remains in use after successful connection, preventing connections to multiple servers

3 participants