Compare commits

..

23 Commits

Author SHA1 Message Date
Joseph Doherty 4f537a5652 Merge remote-tracking branch 'origin/master' into test/task-assertion-return-discard
v2-ci / unit-tests (pull_request) Successful in 1h1m9s
2026-10-13 19:03:52 -04:00
dohertj2 c89912a93b Merge pull request 'Fix DB-less ServiceLevel freshness and authorization remarks' (#724) from fix/dbless-servicelevel-freshness into master
v2-ci / unit-tests (push) Waiting to run
2026-10-13 19:03:42 -04:00
Joseph Doherty c4b4046832 test: verify missing-snapshot ServiceLevel timer behavior
v2-ci / unit-tests (pull_request) Successful in 1h0m42s
2026-10-13 17:19:50 -04:00
Joseph Doherty eed7cc7d75 test: guarantee driver fixture cleanup without masking failures 2026-10-13 16:36:38 -04:00
Joseph Doherty 5310a7e07a Discard asserted probe-task return values without joining loops 2026-10-13 16:12:31 -04:00
Joseph Doherty e1feb9fbb6 Merge remote-tracking branch 'origin/master' into fix/dbless-servicelevel-freshness
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 15:58:35 -04:00
dohertj2 1bf8c614a9 Merge pull request 'Retain ServiceLevel requests across binding and publisher failures' (#720) from fix/servicelevel-binding-replay into master
v2-ci / unit-tests (push) Waiting to run
2026-10-13 15:58:19 -04:00
Joseph Doherty bc80b53e0e Merge ServiceLevel replay fixes for freshness integration 2026-10-13 15:11:20 -04:00
Joseph Doherty f478b844a1 docs: clarify anonymous session ACL enforcement 2026-10-13 14:29:00 -04:00
Joseph Doherty 1e71a25e3d fix: age DB-less ServiceLevel snapshots periodically 2026-10-13 14:29:00 -04:00
Joseph Doherty 8828e999c4 Merge remote-tracking branch 'origin/master' into fix/servicelevel-binding-replay
v2-ci / unit-tests (pull_request) Successful in 1h1m55s
2026-10-13 13:56:26 -04:00
dohertj2 4f7a07fbd3 Merge pull request 'Count individual skipped TRX results' (#714) from fix/trx-skip-reporting into master
v2-ci / unit-tests (push) Has been cancelled
2026-10-13 13:56:04 -04:00
Joseph Doherty da2e441bb9 Merge branch 'fix/trx-skip-reporting' of https://gitea.dohertylan.com/dohertj2/lmxopcua into fix/servicelevel-binding-replay
v2-ci / unit-tests (pull_request) Has been cancelled
2026-10-13 13:54:54 -04:00
Joseph Doherty c8e3e279b4 Retain and replay ServiceLevel across publisher binding and failures
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 13:26:27 -04:00
Joseph Doherty 468f13cb56 Merge remote-tracking branch 'origin/master' into fix/trx-skip-reporting
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 11:55:13 -04:00
Joseph Doherty f71fe13826 docs: record successful bounded CI baseline 2026-10-13 11:55:13 -04:00
dohertj2 6f0674296b Merge pull request 'Clarify S7 counter evidence and fixture limits' (#715) from docs/s7-counter-evidence into master
v2-ci / unit-tests (push) Has been cancelled
2026-10-13 11:54:57 -04:00
dohertj2 19bc13bf6a Merge pull request 'Bound CI concurrency and stabilize test fixtures' (#711) from fix/bootstrap-and-boundary-test-fixtures into master
v2-ci / unit-tests (push) Waiting to run
2026-10-13 11:54:15 -04:00
Joseph Doherty cd50db136e docs: clarify S7 counter evidence and fixture limits 2026-10-13 11:34:59 -04:00
Joseph Doherty eecf301200 ci: count individual skipped TRX results 2026-10-13 11:05:49 -04:00
Joseph Doherty 76bdca90e9 ci: bound build and test concurrency
v2-ci / unit-tests (pull_request) Successful in 1h1m1s
2026-10-13 10:52:12 -04:00
Joseph Doherty 8ff9f38bbd Make bootstrap cancellation test deterministic and deduplicate boundary cases 2026-10-13 09:56:37 -04:00
dohertj2 495606b9fb Merge pull request 'Clarify Galaxy native acknowledgement outcome documentation' (#708) from docs/galaxy-ack-rationale into master
v2-ci / build (push) Successful in 7m24s
v2-ci / unit-tests (push) Failing after 31m46s
2026-10-13 09:10:19 -04:00
38 changed files with 1762 additions and 211 deletions
+19 -22
View File
@@ -1,7 +1,6 @@
# CI for the v2 branch — runs on every push + PR to the v2-akka-fuse / master
# branches. Layered into two jobs:
# build dotnet restore + build (fast feedback on compile errors)
# unit-tests the WHOLE solution minus the env-gated tiers (self-maintaining)
# branches. One job restores and builds once, then tests the WHOLE solution
# minus the env-gated tiers (self-maintaining).
#
# Skips E2E (Category=E2E) and LiveIntegration (needs a real gateway/PLC/VPN) — both
# are env-gated tiers with no hosted-runner fixtures. (The skip-gated *.IntegrationTests
@@ -20,38 +19,35 @@ on:
branches: [v2-akka-fuse, master]
workflow_dispatch: {}
# #707: one active run per repository across push/PR triggers. Gitea 1.26 supports
# workflow concurrency; newer pending runs can supersede an older pending run.
concurrency:
group: lmxopcua-v2-ci
cancel-in-progress: false
env:
DOTNET_NOLOGO: "1"
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
jobs:
build:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# #606: code-reviews/README.md must be regenerated from the per-module findings.md
# files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5).
# #606: regenerated review summary must remain in sync with module findings.
- name: code-reviews README drift check
run: python3 code-reviews/regen-readme.py --check
- name: TRX skip guard fixtures
run: python3 scripts/ci/test-assert-not-all-skipped.py
# #707: bound project workers and avoid concurrent NuGet restore work.
- name: dotnet restore
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx --disable-parallel -m:2
- name: dotnet build
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release
unit-tests:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: dotnet restore
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release -m:2
# #606: the runner's size is the context every timing-sensitive failure needs and the log never
# recorded it (load average included: the two Gitea runners may be running jobs side by side).
- name: runner capacity
@@ -69,14 +65,15 @@ jobs:
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
# #606: Akka TestKit time dilation for the Runtime + ControlPlane actor suites (read by their
# harnesses, tests/Server/*/Harness/TestTimeFactor.cs). This leg runs every test assembly at once
# on the runner, and their 2–8 s presence budgets — sized on an idle dev box — failed in a long
# harnesses, tests/Server/*/Harness/TestTimeFactor.cs). Their 2–8 s presence budgets
# — sized on an idle dev box — failed in a long
# tail, a different test per run. Dilation only stretches waits: a presence wait still fails if
# the message never comes, and absence windows get longer (stricter). Local runs stay at 1.
env:
AKKA_TEST_TIMEFACTOR: "3"
run: >
dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-restore
dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-build
-m:1 -p:TestTfmsInParallel=false --settings scripts/ci/ci.runsettings
--filter "Category!=E2E&Category!=LiveIntegration"
--logger "trx" --results-directory artifacts/trx
# 07/S-2 fail-on-skip: a whole-solution run that executed ZERO tests is a masked outage
+45 -7
View File
@@ -198,15 +198,53 @@ Timers are **read-only** this phase; a write attempt returns `BadNotWritable`.
### Counter read
**Address**: `C{n}` (e.g. `C0`, `C10`). **DataType must be `Int32`**. The OPC UA node is
a scalar `Int32` whose value is the current **count** (raw word from the PLC).
a scalar `Int32` containing the unsigned **raw big-endian word**, widened to `Int32`:
`(bytes[0] << 8) | bytes[1]`. `S7.Net`'s `Counter.FromByteArray` performs no BCD
reinterpretation; for example, bytes `01 23` surface as `291`. This describes the
current decoder, not verified agreement with a PLC's displayed count.
Counters are **read-only** this phase; a write attempt returns `BadNotWritable`.
> **Known limitation — Counter BCD encoding on S7-300/400**: `S7.Net`'s
> `Counter.FromByteArray` returns the raw big-endian word without BCD decode.
> On **classic S7-300/400** the C-area word is BCD-encoded (0–999), so the
> surfaced value can differ from the actual count on that hardware. S7-1200/1500
> use IEC/DB counters (plain integers) where the raw word is correct. BCD
> reinterpretation for legacy C-area is a live-hardware-gated follow-up.
The S7comm ReadVar **C-area response encoding remains unverified on hardware**.
PLC instruction representations do not by themselves establish network encoding:
Siemens documents a binary current value for [L: Load counter](https://docs.tia.siemens.cloud/r/en-us/v21/stl-s7-300-s7-400/stl-mnemonic-s7-300-s7-400/counter-operations-s7-300-s7-400/l-load-counter-s7-300-s7-400)
and separate `CV` and `CV_BCD` outputs for [S_CD](https://docs.tia.siemens.cloud/r/en-us/v21/lad-s7-300-s7-400/counter-operations-s7-300-s7-400/simatic-counters-s7-300-s7-400/s_cd-assign-parameters-and-count-down-s7-300-s7-400?contentId=reR_xapFX_Ike2opHIvfzg).
IEC counter values stored in DB memory use the **DB address and declared integer
type**, a different read path from `C{n}`. CPU family alone does not select the
counter kind or prove its wire representation. In particular, Siemens also lists
legacy SIMATIC counters for S7-1500 in its [instruction comparison (pages 23–24)](https://cache.industry.siemens.com/dl/files/777/109986777/att_1322640/v1/s7_1500_compare_table_en.pdf?download=true).
The driver has no counter encoding selector; hardware-faithful counter support
requires independent raw-response evidence before any interpretation change.
### Counter evidence follow-up (#616)
The existing `S7_1500SpanReadTests` exercises the python-snap7 profile's synthetic
raw words (`C0 = 4711`, `C7 = 31337`). These are outside the classic SIMATIC count
range `0..999` documented by S_CD. These expectations describe raw-word handling;
they do not establish classic counter count semantics. The recorded device-0 run cannot prove nonzero
numbering. The nonzero test is environment-gated, but its options remain
`CpuType = S71500`, rack `0`, slot `0`: setting `S7_TIMER_NUMBER_ADDRESSING=1`
does not configure a classic CPU or make the profile a classic counter gate.
Before claiming hardware validation or closing #616:
- Prepare a separate target-specific semantic gate with the actual CPU type,
rack, slot and TSAP/connection settings. Keep the synthetic raw-word profile
as its own evidence of the existing decoder contract.
- Record CPU model/order number, firmware, endpoint, connection settings and
driver/S7netplus plus simulator versions where applicable.
- Observe independent PLC-side current counts and binary/BCD reference outputs
for valid classic counts such as `123`, a second distinguishing count (`42`
or `999`), and zero. Record the instruction/counter kind and DB reference
addresses; do not derive reference values from the driver's output.
- Capture the actual two-byte S7comm C-area responses alongside those references.
Do not infer response bytes from a simulator seed or expected BCD value.
- Verify several distinct nonzero timer/counter numbers against their independent
PLC values and captured requests/responses, and verify the read-only contract.
The raw-byte/count and nonzero-numbering gates remain open. Password/protection
support inherited from #303/#632 is a separate follow-up, not evidence supplied
by these counter checks.
### Deferrals (not yet implemented)
+53
View File
@@ -0,0 +1,53 @@
# Whole-solution CI
The `v2-ci` workflow restores, builds and tests in one job so the test step uses
that job's Release binaries. Restore disables parallel package work; restore and
build use at most two MSBuild workers. Testing uses one MSBuild worker, disables
parallel target-framework runs and loads `ci.runsettings`: one VSTest process and
two xUnit collection workers with conservative scheduling. Assemblies that disable
parallelization keep that setting.
The workflow retains the E2E/LiveIntegration exclusions, includes SlowUnit, keeps
Akka's test time factor at three, and requires at least 12,000 executed tests.
Runner capacity and TRX files remain available for diagnosing failures. These
limits reduce contention; they do not prove every timing failure has one cause.
There are no automatic test retries or changed assertion deadlines.
Gitea 1.26 supports the repository-wide `lmxopcua-v2-ci` concurrency group. Only
one run is active; an active run is not cancelled, but a newer pending run may
supersede an older pending run. The group does not serialize other repositories
using the same host.
To reproduce the bounded test leg after restore and Release build:
```bash
AKKA_TEST_TIMEFACTOR=3 dotnet test ZB.MOM.WW.OtOpcUa.slnx \
--configuration Release --no-build -m:1 -p:TestTfmsInParallel=false \
--settings scripts/ci/ci.runsettings \
--filter "Category!=E2E&Category!=LiveIntegration" \
--logger trx --results-directory artifacts/trx
MIN_EXECUTED=12000 bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx
```
The guard uses the existing TRX `Counters/@executed` value for the aggregate
floor. Its `skipped` diagnostic counts individual `Results/UnitTestResult`
records whose outcome is `NotExecuted`, using the document's XML namespace.
Some VSTest reports set summary `notExecuted` to zero even when these records
are present. Skip records are counted per file, including repeated runs; they
are not a count of unique test methods and do not affect the executed floor.
For malformed XML, the diagnostic retains the old summary-counter fallback;
this guard does not add XML validation to its existing gate policy.
Run the deterministic script fixtures without building or running the solution:
```bash
python3 scripts/ci/test-assert-not-all-skipped.py
```
Verified hosted baseline: [Gitea run 975](https://gitea.dohertylan.com/dohertj2/lmxopcua/actions/runs/975)
completed successfully on 2026-10-13 for commit
`76bdca90e94e814e06ef10b78271e2b492b2ba32` (merged by PR #711).
Restore, Release build, README drift check, the serialized whole-solution test
step, the 12,000-test floor and TRX upload all passed. The floor counted
14,630 executed tests across 62 TRX files. This baseline predates the individual
skip-reporting correction; its printed zero skip diagnostic is not skip evidence.
+20 -1
View File
@@ -33,12 +33,31 @@ for f in "$@"; do
exit 2
fi
seen=$((seen + 1))
# The trx <Counters .../> element in <ResultSummary> carries executed + notExecuted counts.
# Keep the executed-counter floor unchanged; notExecuted is only a fallback
# for malformed XML that this guard historically accepted.
counters="$(grep -oE '<Counters[^>]*/?>' "$f" | head -1)"
ex="$(printf '%s' "$counters" | grep -oE 'executed="[0-9]+"' | grep -oE '[0-9]+' || true)"
ne="$(printf '%s' "$counters" | grep -oE 'notExecuted="[0-9]+"' | grep -oE '[0-9]+' || true)"
ex="${ex:-0}"
ne="${ne:-0}"
# VSTest can emit notExecuted="0" despite individual NotExecuted results.
# Count direct Results/UnitTestResult records in the document's namespace,
# excluding foreign elements, nested records and escaped diagnostic text.
ne="$(python3 - "$f" "$ne" <<'PY'
import sys
import xml.etree.ElementTree as ET
try:
root = ET.parse(sys.argv[1]).getroot()
except ET.ParseError:
# Skip reporting must not introduce a new malformed-XML gate failure.
print(sys.argv[2])
else:
namespace = root.tag.split("}", 1)[0] + "}" if root.tag.startswith("{") else ""
results = root.findall(f"{namespace}Results/{namespace}UnitTestResult")
print(sum(result.get("outcome") == "NotExecuted" for result in results))
PY
)"
total_executed=$((total_executed + ex))
total_skipped=$((total_skipped + ne))
echo " $(basename "$f"): executed=${ex} skipped=${ne}"
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<!-- #707: project scheduling is also bounded by dotnet test -m:1. -->
<RunConfiguration>
<MaxCpuCount>1</MaxCpuCount>
</RunConfiguration>
<xUnit>
<!-- Preserve each assembly's parallelization opt-out; bound enabled collections. -->
<MaxParallelThreads>2</MaxParallelThreads>
<ParallelAlgorithm>conservative</ParallelAlgorithm>
</xUnit>
</RunSettings>
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Deterministic subprocess checks for the TRX executed-floor guard."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).with_name("assert-not-all-skipped.sh")
NS = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"
def trx(executed, outcomes, summary_skipped=0, namespace=NS, extra=""):
declaration = f' xmlns="{namespace}"' if namespace else ""
results = "".join(f'<UnitTestResult outcome="{outcome}"/>' for outcome in outcomes)
return (f'<TestRun{declaration}><Results>{results}{extra}</Results>'
f'<ResultSummary><Counters total="{len(outcomes)}" executed="{executed}" '
f'notExecuted="{summary_skipped}"/></ResultSummary></TestRun>')
class SkipGuardTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="trx-skip-fixtures-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
def write(self, name, content):
path = self.root / name
path.write_text(content, encoding="utf-8")
return path
def run_guard(self, *paths, minimum=None):
env = os.environ.copy()
env.pop("MIN_EXECUTED", None)
if minimum is not None:
env["MIN_EXECUTED"] = str(minimum)
return subprocess.run(["bash", str(SCRIPT), *map(str, paths)], env=env,
text=True, capture_output=True, check=False)
def assert_report(self, result, status, executed, skipped, files=1):
self.assertEqual(status, result.returncode, result.stdout + result.stderr)
self.assertIn(f"files={files} executed={executed} skipped={skipped} ", result.stdout)
def test_mixed_results_ignore_zero_summary_skip_counter(self):
# A summary-only skip count misses the three actual NotExecuted records.
path = self.write("mixed.trx", trx(827, ["Passed"] * 827 + ["NotExecuted"] * 3))
result = self.run_guard(path)
self.assert_report(result, 0, 827, 3)
self.assertIn("mixed.trx: executed=827 skipped=3", result.stdout)
def test_all_skipped_fails_default_floor_but_report_only_passes(self):
path = self.write("all-skipped.trx", trx(0, ["NotExecuted"] * 188))
self.assert_report(self.run_guard(path), 1, 0, 188)
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 188)
def test_zero_tests_fails_default_floor_but_report_only_passes(self):
path = self.write("zero.trx", trx(0, []))
self.assert_report(self.run_guard(path), 1, 0, 0)
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 0)
def test_agreeing_summary_and_outcomes_are_not_double_counted(self):
path = self.write("agreeing.trx", trx(1, ["Passed", "NotExecuted"], 1))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_outcomes_are_authoritative_when_summary_overstates_skips(self):
path = self.write("overstated.trx", trx(1, ["Passed", "NotExecuted"], 9))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_namespace_path_and_exact_outcome_exclude_decoys(self):
extra = ('<Other><UnitTestResult outcome="NotExecuted"/></Other>'
'<foreign:UnitTestResult xmlns:foreign="urn:other" outcome="NotExecuted"/>'
'<Output><Message>&lt;UnitTestResult outcome="NotExecuted"/&gt;</Message></Output>')
content = trx(2, ["Passed", "Failed", "NotExecuted", "notexecuted"], extra=extra)
# Prefixes on Results/UnitTestResult must work just like the default namespace.
content = content.replace('<TestRun ', f'<TestRun xmlns:t="{NS}" ')
content = content.replace('<Results>', '<t:Results>').replace('</Results>', '</t:Results>')
content = content.replace('<UnitTestResult ', '<t:UnitTestResult ')
path = self.write("namespaced.trx", content)
self.assert_report(self.run_guard(path), 0, 2, 1)
def test_unnamespaced_results_remain_supported(self):
path = self.write("legacy.trx", trx(1, ["Passed", "NotExecuted"], namespace=""))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_executed_counter_controls_aggregate_floor_at_boundary(self):
# Counters retain authority even when result records disagree with executed.
mixed = self.write("floor.trx", trx(12000, ["Passed", "NotExecuted"]))
skipped = self.write("skipped.trx", trx(0, ["NotExecuted"]))
self.assert_report(self.run_guard(mixed, skipped, minimum=12000), 0, 12000, 2, 2)
self.assert_report(self.run_guard(mixed, skipped, minimum=12001), 1, 12000, 2, 2)
def test_missing_arguments_and_file_keep_exit_two(self):
self.assertEqual(2, self.run_guard().returncode)
self.assertEqual(2, self.run_guard(self.root / "missing.trx").returncode)
def test_missing_counters_keeps_existing_exit_one(self):
path = self.write("no-counters.trx", '<TestRun><Results/></TestRun>')
self.assertEqual(1, self.run_guard(path, minimum=0).returncode)
def test_malformed_xml_keeps_summary_fallback_and_gate_status(self):
path = self.write("malformed.trx", '<TestRun><Counters executed="2" notExecuted="3"/>')
self.assert_report(self.run_guard(path, minimum=2), 0, 2, 3)
self.assert_report(self.run_guard(path, minimum=3), 1, 2, 3)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -1,21 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
/// Late-binding adapter that accepts and retains the latest requested ServiceLevel until
/// the SDK publisher is available. Binding synchronously replays that request, including zero.
/// </summary>
/// <remarks>
/// Publishing and binding are serialized through the synchronous inner call. Inner publishers
/// must not call back into this adapter. Acceptance while unbound is not an SDK write confirmation.
/// A failed call retains both the requested level and the installed publisher for later recovery.
/// </remarks>
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
{
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
private readonly object _gate = new();
private IServiceLevelPublisher? _inner;
private byte? _latest;
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
/// <param name="inner">The publisher implementation to use, or null to use the null publisher.</param>
public void SetInner(IServiceLevelPublisher? inner) =>
_inner = inner ?? NullServiceLevelPublisher.Instance;
/// <summary>Bind and replay the latest request, or unbind while retaining that request.</summary>
/// <param name="inner">The publisher implementation to use, or null to unbind.</param>
public void SetInner(IServiceLevelPublisher? inner)
{
lock (_gate)
{
_inner = inner;
if (_latest is { } latest) _inner?.Publish(latest);
}
}
/// <inheritdoc />
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
public void Publish(byte serviceLevel)
{
lock (_gate)
{
_latest = serviceLevel;
_inner?.Publish(serviceLevel);
}
}
}
@@ -1465,7 +1465,7 @@ public sealed class S7Driver
{
// AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
// S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
// a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns
// a Counter (C{n}) exposes its 2-byte RAW WORD via Counter.FromByteArray (returns
// ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
// here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
// below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
@@ -1474,11 +1474,12 @@ public sealed class S7Driver
return (object)global::S7.Net.Types.Timer.FromByteArray(block);
if (addr.Area is S7Area.Counter)
{
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
// NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
// S7.Net Counter.FromByteArray returns the raw big-endian word ((bytes[0]<<8)|bytes[1]),
// without BCD reinterpretation. This preserves the raw value, not a verified PLC count:
// S7comm C-area response encoding still needs independent raw bytes and PLC-side
// count/binary/BCD references (#616). CPU family alone does not prove that encoding.
// IEC counter integers in DB memory take the DB type codec, not this C-area path;
// S7-1500 also has legacy SIMATIC counters. See docs/drivers/S7.md for the evidence gate.
return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
}
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>Coordinates endpoint cleanup after ServiceLevel binding fails during startup.</summary>
internal static class FailedServiceLevelBindingCleanup
{
/// <summary>Stops and disposes the endpoint, returning whether ownership can be released.</summary>
/// <param name="stop">The SDK stop operation.</param>
/// <param name="dispose">The application-host disposal operation.</param>
/// <param name="logger">The cleanup logger.</param>
/// <returns>Whether cleanup completed successfully.</returns>
internal static async Task<bool> StopAndDisposeAsync(
Func<Task> stop, Func<ValueTask> dispose, ILogger logger)
{
var completed = true;
try
{
await stop().ConfigureAwait(false);
}
catch (Exception ex)
{
completed = false;
logger.LogError(ex, "OtOpcUaServerHostedService: SDK stop failed after binding failure; retaining endpoint ownership for shutdown retry");
}
// Attempt application cleanup even when stopping faults; keep ownership on either failure.
try
{
await dispose().ConfigureAwait(false);
}
catch (Exception ex)
{
completed = false;
logger.LogError(ex, "OtOpcUaServerHostedService: endpoint disposal failed after binding failure; retaining endpoint ownership for shutdown retry");
}
return completed;
}
}
@@ -339,11 +339,36 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server.NodeManager.Clock = historyUpdate.Clock;
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
try
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
if (_server.CurrentInstance is { } serverInternal)
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"OtOpcUaServerHostedService: ServiceLevel binding failed; stopping the OPC UA endpoint while the admin host continues");
try
{
await StopAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception cleanupError)
{
_logger.LogError(cleanupError, "OtOpcUaServerHostedService: failed to detach OPC UA bindings after startup failure");
}
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
async () => await _server.StopAsync(CancellationToken.None).ConfigureAwait(false),
() => _appHost.DisposeAsync(), _logger).ConfigureAwait(false);
if (released)
{
_appHost = null;
_server = null;
}
return;
}
// arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
@@ -69,8 +69,10 @@ public sealed class OpcUaApplicationHostOptions
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> rows naming it,
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
/// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole
/// address space, because reads carry no authorization check yet.
/// When a wired data-plane authorizer is enabled, per-node ACL checks apply to anonymous
/// sessions, including reads. Reads denied by the node manager return <c>BadUserAccessDenied</c>.
/// With the authorizer disabled or not wired, reads retain the legacy behaviour without
/// per-node ACL enforcement.
/// </para>
/// </remarks>
public bool AllowAnonymous { get; set; } = true;
@@ -558,10 +560,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
try
{
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
// An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
sessionManager.ImpersonateUser -= _impersonateHandler;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw");
}
_impersonateHandler = null;
@@ -38,8 +38,8 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
var node = _serverInternal.ServerObject?.ServiceLevel;
if (node is null)
{
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write");
return;
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable");
throw new InvalidOperationException("Server.ServiceLevel is unavailable.");
}
try
@@ -55,6 +55,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
catch (Exception ex)
{
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
throw;
}
}
}
@@ -147,6 +147,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private readonly TimeSpan _staleWindow;
private readonly TimeSpan _probeFreshnessWindow;
private readonly TimeSpan _healthTickInterval;
private readonly Func<DateTime> _utcNow;
private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System);
private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -200,9 +201,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="healthTickInterval">The period between freshness ticks; defaults to 5 seconds.
/// DB-backed nodes Ask the probe for cached status; DB-less nodes recompute snapshot freshness.
/// Legacy nodes without a probe do not start a timer.</param>
/// <param name="utcNow">The UTC clock used for freshness; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
@@ -219,7 +221,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -232,7 +235,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
probeFreshnessWindow,
healthTickInterval,
dbLess,
dataPlaneAuthority)).WithDispatcher(DispatcherId);
dataPlaneAuthority,
utcNow)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
@@ -249,9 +253,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="healthTickInterval">The period between freshness ticks; defaults to 5 seconds.
/// DB-backed nodes Ask the probe for cached status; DB-less nodes recompute snapshot freshness.
/// Legacy nodes without a probe do not start a timer.</param>
/// <param name="utcNow">The UTC clock used for freshness; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
@@ -269,7 +274,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -282,7 +288,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
probeFreshnessWindow,
healthTickInterval,
dbLess,
dataPlaneAuthority));
dataPlaneAuthority,
utcNow));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
@@ -298,9 +305,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="healthTickInterval">The period between freshness ticks; defaults to 5 seconds.
/// DB-backed nodes Ask the probe for cached status; DB-less nodes recompute snapshot freshness.
/// Legacy nodes without a probe do not start a timer.</param>
/// <param name="utcNow">The UTC clock used for freshness; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
@@ -316,7 +324,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null)
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null)
{
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
@@ -330,6 +339,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
_staleWindow = staleWindow ?? TimeSpan.FromSeconds(30);
_probeFreshnessWindow = probeFreshnessWindow ?? TimeSpan.FromSeconds(30);
_healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5);
_utcNow = utcNow ?? (() => DateTime.UtcNow);
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);
@@ -353,11 +363,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(RedundancyStateTopic, Self));
}
// Production delivery of DB-health: when a probe is wired, kick an immediate refresh + start the
// periodic tick so _lastDbHealth gets populated and kept fresh without any external pump. Gated
// ONLY on the probe being present (independent of the DPS subscribe) so the calculator path works
// in tests too. No probe → stay on the legacy role-only seam (no timer).
if (_dbHealthProbe is not null)
// DB-backed nodes refresh their cached probe status; DB-less nodes still need periodic
// snapshot/probe-verdict age checks even when no new messages arrive. Legacy nodes keep
// their role-only behavior without a timer.
if (_dbLess || _dbHealthProbe is not null)
{
Self.Tell(HealthTick.Instance); // immediate first refresh
Timers.StartPeriodicTimer("db-health", HealthTick.Instance, _healthTickInterval);
@@ -970,10 +979,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// node starting Detached/role-less (first level = 0) would be dedup'd away, leaving the SDK's
// built-in default (255 = full service) standing — a degraded node wrongly advertising 255.
if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return;
_lastServiceLevel = msg.ServiceLevel;
try
{
_serviceLevel.Publish(msg.ServiceLevel);
_lastServiceLevel = msg.ServiceLevel;
_publishedAtLeastOnce = true;
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
@@ -981,6 +990,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
catch (Exception ex)
{
// A failed write may be partially applied; even a previous successful value must retry.
_publishedAtLeastOnce = false;
_log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel);
}
}
@@ -1004,7 +1015,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
RecomputeServiceLevel();
}
/// <summary>Periodic self-tick: Asks the local <see cref="DbHealthProbeActor"/> for its cached
/// <summary>Periodic self-tick: recomputes freshness directly when no DB probe exists; otherwise
/// Asks the local <see cref="DbHealthProbeActor"/> for its cached
/// status and pipes the reply back to <see cref="Self"/>, where <see cref="HandleDbHealthStatus"/>
/// caches it + recomputes. A hung/late probe (Ask timeout) pipes a <c>Reachable=false</c> status so
/// the node FAIL-SAFE-DEMOTES rather than freezing the last-known-good. The continuation only
@@ -1013,11 +1025,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// touched here.</summary>
private void OnHealthTick()
{
_dbHealthProbe!.Ask<DbHealthProbeActor.DbHealthStatus>(
if (_dbHealthProbe is null)
{
RecomputeServiceLevel();
return;
}
_dbHealthProbe.Ask<DbHealthProbeActor.DbHealthStatus>(
DbHealthProbeActor.GetStatus.Instance, TimeSpan.FromSeconds(1))
.ContinueWith(t => t.IsCompletedSuccessfully
? t.Result
: new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "db-health ask timeout"))
: new DbHealthProbeActor.DbHealthStatus(false, _utcNow(), "db-health ask timeout"))
.PipeTo(Self);
}
@@ -1030,7 +1048,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
// The result targets the probed node. If it isn't me, it's about a peer — ignore it.
if (_localNode is null || r.NodeId != _localNode.Value) return;
_probeAboutMe = (r.Ok, DateTime.UtcNow);
_probeAboutMe = (r.Ok, _utcNow());
RecomputeServiceLevel();
}
@@ -1042,7 +1060,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private bool OpcUaProbeOk()
{
if (_probeAboutMe is not { } verdict) return true;
if (DateTime.UtcNow - verdict.At > _probeFreshnessWindow) return true;
if (_utcNow() - verdict.At > _probeFreshnessWindow) return true;
return verdict.Ok;
}
@@ -1079,7 +1097,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// wins, so a DB-less Detached node stays 0.
if (_dbLess)
{
var utcNow = DateTime.UtcNow;
var utcNow = _utcNow();
var dbLessInputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: true,
@@ -1099,7 +1117,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return;
}
var now = DateTime.UtcNow;
var now = _utcNow();
var inputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: _lastDbHealth.Reachable,
@@ -10,10 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
public class DeferredServiceLevelPublisherTests
{
[Fact]
public void Before_SetInner_Publish_is_a_noop()
public void Before_SetInner_Publish_accepts_a_cached_request()
{
var publisher = new DeferredServiceLevelPublisher();
// Must not throw; the NullServiceLevelPublisher absorbs it.
// Accept the request before an SDK publisher exists.
publisher.Publish(200);
}
@@ -30,7 +30,7 @@ public class DeferredServiceLevelPublisherTests
}
[Fact]
public void SetInner_null_reverts_to_null_publisher()
public void SetInner_null_detaches_the_previous_publisher()
{
var spy = new SpyPublisher();
var publisher = new DeferredServiceLevelPublisher();
@@ -39,8 +39,139 @@ public class DeferredServiceLevelPublisherTests
publisher.Publish(100);
// The spy should NOT have received the publish after revert.
spy.LastPublished.ShouldBe((byte)0, "spy should not have been updated after revert to null");
// The previous publisher must not receive an unbound request.
spy.LastPublished.ShouldBe((byte)0, "the previous publisher should not be updated after unbinding");
}
[Fact]
public void Binding_without_a_request_does_not_invent_a_level()
{
var received = new List<byte>();
new DeferredServiceLevelPublisher().SetInner(new CallbackPublisher(received.Add));
received.ShouldBeEmpty();
}
[Theory]
[InlineData((byte)100)]
[InlineData((byte)0)]
public void Binding_replays_the_latest_request_including_zero(byte latest)
{
var publisher = new DeferredServiceLevelPublisher();
publisher.Publish(240);
publisher.Publish(latest);
var received = new List<byte>();
publisher.SetInner(new CallbackPublisher(received.Add));
received.ShouldBe(new[] { latest });
}
[Fact]
public void Unbinding_retains_latest_for_rebinding()
{
var publisher = new DeferredServiceLevelPublisher();
var old = new List<byte>();
publisher.SetInner(new CallbackPublisher(old.Add));
publisher.Publish(240);
publisher.SetInner(null);
publisher.Publish(0);
var replacement = new List<byte>();
publisher.SetInner(new CallbackPublisher(replacement.Add));
old.ShouldBe(new byte[] { 240 });
replacement.ShouldBe(new byte[] { 0 });
}
[Fact]
public void Failed_publish_retains_the_requested_value_for_replay()
{
var publisher = new DeferredServiceLevelPublisher();
publisher.SetInner(new CallbackPublisher(_ => throw new InvalidOperationException()));
Should.Throw<InvalidOperationException>(() => publisher.Publish(0));
var received = new List<byte>();
publisher.SetInner(new CallbackPublisher(received.Add));
received.ShouldBe(new byte[] { 0 });
}
[Fact]
public void Failed_replay_keeps_candidate_bound_for_later_publish()
{
var publisher = new DeferredServiceLevelPublisher();
publisher.Publish(100);
var fail = true;
var received = new List<byte>();
var candidate = new CallbackPublisher(value =>
{
if (fail) throw new InvalidOperationException();
received.Add(value);
});
Should.Throw<InvalidOperationException>(() => publisher.SetInner(candidate));
fail = false;
publisher.Publish(0);
publisher.SetInner(null);
publisher.SetInner(candidate);
received.ShouldBe(new byte[] { 0, 0 });
}
[Fact]
public void Failed_replay_retains_latest_for_a_replacement_publisher()
{
var publisher = new DeferredServiceLevelPublisher();
publisher.Publish(100);
Should.Throw<InvalidOperationException>(() => publisher.SetInner(
new CallbackPublisher(_ => throw new InvalidOperationException())));
var received = new List<byte>();
publisher.SetInner(new CallbackPublisher(received.Add));
received.ShouldBe(new byte[] { 100 });
}
[Fact]
public async Task Binding_waits_for_inflight_publish_then_replays_latest()
{
var publisher = new DeferredServiceLevelPublisher();
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
publisher.SetInner(new CallbackPublisher(_ =>
{
entered.Set();
release.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue();
}));
var publish = Task.Run(() => publisher.Publish(100), TestContext.Current.CancellationToken);
var received = new List<byte>();
using var bindingStarted = new ManualResetEventSlim();
Exception? bindingError = null;
var binding = new Thread(() =>
{
bindingStarted.Set();
try { publisher.SetInner(new CallbackPublisher(received.Add)); }
catch (Exception ex) { bindingError = ex; }
});
var bindingWasStarted = false;
try
{
entered.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue();
binding.Start();
bindingWasStarted = true;
bindingStarted.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue();
// Past the marker, SetInner is the thread's only potentially blocking operation.
// Observe actual contention rather than assuming Task.Run has entered the adapter.
SpinWait.SpinUntil(() => !binding.IsAlive ||
(binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0,
TimeSpan.FromSeconds(3)).ShouldBeTrue();
(binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin).ShouldNotBe(
(System.Threading.ThreadState)0, "binding must wait for the in-flight delivery");
}
finally
{
release.Set();
if (bindingWasStarted) binding.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue();
await publish.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
bindingError.ShouldBeNull();
publisher.Publish(0);
received.ShouldBe(new byte[] { 100, 0 });
}
private sealed class CallbackPublisher(Action<byte> publish) : IServiceLevelPublisher
{
public void Publish(byte serviceLevel) => publish(serviceLevel);
}
// ---- test double ----
@@ -1,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// orphaned the previous state's probe loop + handles. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReinitializeReleasesStateTests
public sealed class AbCipReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.</summary>
[Fact]
@@ -27,28 +28,94 @@ public sealed class AbCipReinitializeReleasesStateTests
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and has initialised its runtime.
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and has initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["@raw_cpu_type"];
var firstLoop = first.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"AbCip SecondInitialize cleanup failed: {failure}"));
}
/// <summary>Failure after a live first or replacement state must release both owned resources.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(20),
Timeout = TimeSpan.FromMilliseconds(200),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-reinit", factory);
Task? firstLoop = null, secondLoop = null;
FakeAbCipTag? firstTag = null, secondTag = null;
var sentinel = new InvalidOperationException("body sentinel");
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForProbeAsync(() => factory.Tags.TryGetValue("@raw_cpu_type", out var tag) && tag.InitializeCount > 0);
firstTag = factory.Tags["@raw_cpu_type"];
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
if (afterReplacement)
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForProbeAsync(() => factory.Tags.TryGetValue("@raw_cpu_type", out var tag) && !ReferenceEquals(tag, firstTag) && tag.InitializeCount > 0);
secondTag = factory.Tags["@raw_cpu_type"];
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = secondLoop.ShouldNotBeNull();
secondLoop!.IsCompleted.ShouldBeFalse();
}
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"AbCip failure fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
firstLoop!.IsCompleted.ShouldBeTrue();
firstTag!.Disposed.ShouldBeTrue();
if (afterReplacement)
{
secondLoop!.IsCompleted.ShouldBeTrue();
secondTag!.Disposed.ShouldBeTrue();
}
}
private static async Task WaitForProbeAsync(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["@raw_cpu_type"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,92 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class FixtureCleanupTests
{
[Fact]
public async Task Successful_body_runs_one_awaited_cleanup()
{
var calls = 0;
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var run = FixtureCleanup.RunAsync(() => Task.CompletedTask, async () =>
{
calls++;
await release.Task;
}, _ => throw new Exception("unexpected report"));
run.IsCompleted.ShouldBeFalse();
release.SetResult();
await run;
calls.ShouldBe(1);
}
[Fact]
public async Task Body_fault_remains_same_exception_and_original_throwing_stack()
{
var primary = new InvalidOperationException("body");
var cleanupCalls = 0;
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(
() => ThrowFromBody(primary),
() => { cleanupCalls++; return Task.CompletedTask; },
_ => throw new Exception("unexpected report")));
actual.ShouldBeSameAs(primary);
actual.StackTrace.ShouldNotBeNull().ShouldContain(nameof(ThrowFromBody));
cleanupCalls.ShouldBe(1);
}
[Fact]
public async Task Cleanup_only_fault_is_thrown_exactly()
{
var cleanup = new ApplicationException("cleanup");
var actual = await Should.ThrowAsync<ApplicationException>(() => FixtureCleanup.RunAsync(
() => Task.CompletedTask,
() => Task.FromException(cleanup),
_ => throw new Exception("unexpected report")));
actual.ShouldBeSameAs(cleanup);
}
[Fact]
public async Task Both_faults_preserve_primary_and_record_secondary_once()
{
var primary = new InvalidOperationException("body");
var cleanup = new ApplicationException("cleanup");
var reported = new List<Exception>();
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(
() => ThrowFromBody(primary),
() => Task.FromException(cleanup),
reported.Add));
actual.ShouldBeSameAs(primary);
actual.StackTrace.ShouldNotBeNull().ShouldContain(nameof(ThrowFromBody));
actual.Data[FixtureCleanup.SecondaryExceptionKey].ShouldBeSameAs(cleanup);
reported.Count.ShouldBe(1);
reported[0].ShouldBeSameAs(cleanup);
}
[Fact]
public async Task Reporting_fault_does_not_replace_primary()
{
var primary = new InvalidOperationException("body");
var cleanup = new ApplicationException("cleanup");
var reportCalls = 0;
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(
() => ThrowFromBody(primary),
() => Task.FromException(cleanup),
_ => { reportCalls++; throw new Exception("report"); }));
actual.ShouldBeSameAs(primary);
actual.Data[FixtureCleanup.SecondaryExceptionKey].ShouldBeSameAs(cleanup);
reportCalls.ShouldBe(1);
}
private static async Task ThrowFromBody(Exception failure)
{
await Task.Yield();
throw failure;
}
}
@@ -38,4 +38,7 @@
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipTarget.cs" Link="Fakes\FakeCipTarget.cs" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
</ItemGroup>
</Project>
@@ -1,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// probe loop + runtime. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReinitializeReleasesStateTests
public sealed class AbLegacyReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.</summary>
[Fact]
@@ -23,28 +24,88 @@ public sealed class AbLegacyReinitializeReleasesStateTests
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["S:0"];
var firstLoop = first.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"AbLegacy SecondInitialize cleanup failed: {failure}"));
}
/// <summary>Failure after a live first or replacement state must release both owned resources.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
}, "drv-reinit", factory);
Task? firstLoop = null, secondLoop = null;
FakeAbLegacyTag? firstTag = null, secondTag = null;
var sentinel = new InvalidOperationException("body sentinel");
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForProbeAsync(() => factory.Tags.TryGetValue("S:0", out var tag) && tag.InitializeCount > 0);
firstTag = factory.Tags["S:0"];
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
if (afterReplacement)
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForProbeAsync(() => factory.Tags.TryGetValue("S:0", out var tag) && !ReferenceEquals(tag, firstTag) && tag.InitializeCount > 0);
secondTag = factory.Tags["S:0"];
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = secondLoop.ShouldNotBeNull();
secondLoop!.IsCompleted.ShouldBeFalse();
}
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"AbLegacy failure fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
firstLoop!.IsCompleted.ShouldBeTrue();
firstTag!.Disposed.ShouldBeTrue();
if (afterReplacement)
{
secondLoop!.IsCompleted.ShouldBeTrue();
secondTag!.Disposed.ShouldBeTrue();
}
}
private static async Task WaitForProbeAsync(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["S:0"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -38,4 +38,7 @@
<None Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Captures\*.hex" Link="Captures\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
</ItemGroup>
</Project>
@@ -1,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitializeReleasesStateTests
public sealed class FocasReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
@@ -25,25 +26,78 @@ public sealed class FocasReinitializeReleasesStateTests
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
_ = drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"FOCAS SecondInitialize cleanup failed: {failure}"));
}
/// <summary>Failure after a live first or replacement state must release both owned resources.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
{
const string host = "focas://10.0.0.5:8193";
var factory = new FakeFocasClientFactory();
var options = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
Task? firstLoop = null, secondLoop = null;
FakeFocasClient? firstClient = null, secondClient = null;
var sentinel = new InvalidOperationException("body sentinel");
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
firstClient = factory.Clients[0];
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
if (afterReplacement)
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Clients.Count > 1 && factory.Clients[1].IsConnected);
secondClient = factory.Clients[1];
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = secondLoop.ShouldNotBeNull();
secondLoop!.IsCompleted.ShouldBeFalse();
}
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"FOCAS failure fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
firstLoop!.IsCompleted.ShouldBeTrue();
firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
await drv.ShutdownAsync(CancellationToken.None);
if (afterReplacement)
{
secondLoop!.IsCompleted.ShouldBeTrue();
secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
secondClient.IsConnected.ShouldBeFalse();
}
}
private static async Task WaitUntilAsync(Func<bool> cond)
@@ -34,4 +34,7 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
</ItemGroup>
</Project>
@@ -197,8 +197,6 @@ public sealed class GatewayCeilingPagingTests : IDisposable
[InlineData(L - 1, 5000u)]
[InlineData(L, 5000u)]
[InlineData(L + 1, 5000u)]
[InlineData(Ceiling - 17, 5000u)]
[InlineData(Ceiling - 16, 5000u)]
[InlineData(Ceiling + 1, 5000u)]
[InlineData(L - 2, 0u)]
[InlineData(L - 1, 0u)]
@@ -1,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -16,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
/// defect the happy-path assertions alone would pass straight over.
/// </remarks>
public sealed class MTConnectDriverLifecycleTests
public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output)
{
private const string AgentUri = "http://fixture-agent:5000";
@@ -598,18 +599,79 @@ public sealed class MTConnectDriverLifecycleTests
// same "browse must not be left permanently empty" obligation.
[Fact]
public void Dropping_the_probe_cache_keeps_the_observation_index()
public async Task Dropping_the_probe_cache_keeps_the_observation_index()
{
var (driver, _) = InitializedDriverAsync().GetAwaiter().GetResult();
var (driver, _) = NewDriver();
await FixtureCleanup.RunAsync(async () =>
{
await driver.InitializeAsync("{}", CancellationToken.None);
driver.InvalidateProbeModel();
driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
driver.CachedProbeModel.ShouldBeNull();
// The index is required for correctness (it IS the read surface) — never flushable.
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// The index is required for correctness (it IS the read surface) — never flushable.
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}, () => driver.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"MTConnect cache drop cleanup failed: {failure}"));
}
[Fact]
public async Task Body_failure_after_probe_scheduler_enters_stops_probe_and_disposes_client()
{
var client = CannedAgentClient.FromFixtures();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new MTConnectDriver(Opts(), "mt1", _ => client,
probeScheduler: async (_, ct) =>
{
entered.TrySetResult();
try { await released.Task.WaitAsync(ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
cancelled.TrySetResult();
throw;
}
});
Task? probe = null;
var sentinel = new InvalidOperationException("body sentinel");
try
{
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await driver.InitializeAsync("{}", CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
probe = driver.ProbeLoopTask;
_ = probe.ShouldNotBeNull();
probe!.IsCompleted.ShouldBeFalse();
client.IsDisposed.ShouldBeFalse();
throw sentinel;
}, () => driver.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"MTConnect probe fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
probe!.IsCompleted.ShouldBeTrue();
client.DisposeCount.ShouldBe(1);
}
finally { released.TrySetResult(); }
}
[Fact]
public async Task Initialization_failure_disposes_the_client_inside_fixture_boundary()
{
var client = CannedAgentClient.FromFixtures();
var failure = new HttpRequestException("current unavailable");
client.CurrentFailure = failure;
var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
var actual = await Should.ThrowAsync<HttpRequestException>(() => FixtureCleanup.RunAsync(
() => driver.InitializeAsync("{}", CancellationToken.None),
() => driver.ShutdownAsync(CancellationToken.None),
secondary => output.WriteLine($"MTConnect init fixture cleanup failed: {secondary}")));
actual.ShouldBeSameAs(failure);
client.DisposeCount.ShouldBe(1);
}
[Fact]
@@ -39,4 +39,7 @@
</Content>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
</ItemGroup>
</Project>
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Reflection;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -14,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// unit test alongside them per Driver.Modbus-012.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusLifecycleHygieneTests
public sealed class ModbusLifecycleHygieneTests(ITestOutputHelper output)
{
private sealed class FakeTransport : IModbusTransport
{
@@ -528,7 +529,7 @@ public sealed class ModbusLifecycleHygieneTests
/// <summary>Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException.</summary>
[Fact]
public void DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
public async Task DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
{
var decode = typeof(ModbusDriver).GetMethod(
"DecodeBitArray", BindingFlags.NonPublic | BindingFlags.Static)!;
@@ -542,11 +543,49 @@ public sealed class ModbusLifecycleHygieneTests
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var r = drv.ReadAsync(["Coil"], CancellationToken.None).GetAwaiter().GetResult();
// The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
r[0].StatusCode.ShouldBe(0x80050000u);
var r = await drv.ReadAsync(["Coil"], CancellationToken.None);
// The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
r[0].StatusCode.ShouldBe(0x80050000u);
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"Modbus empty bitmap cleanup failed: {failure}"));
}
[Fact]
public async Task Body_failure_after_probe_request_cancels_it_and_disposes_transport()
{
var fake = new EmptyBitTransport();
var opts = new ModbusDriverOptions
{
Host = "fake",
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
var sentinel = new InvalidOperationException("body sentinel");
try
{
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var result = await drv.ReadAsync(["Coil"], CancellationToken.None);
result[0].StatusCode.ShouldBe(0x80050000u);
fake.CoilRequestCount.ShouldBe(1);
await fake.ProbeEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
fake.ProbeToken.IsCancellationRequested.ShouldBeFalse();
fake.DisposeCount.ShouldBe(0);
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"Modbus probe fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
fake.ProbeToken.IsCancellationRequested.ShouldBeTrue();
await fake.ProbeCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await fake.ProbeRequestCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
fake.DisposeCount.ShouldBe(1);
}
finally { fake.ReleaseProbe.TrySetResult(); }
}
/// <summary>
@@ -556,6 +595,14 @@ public sealed class ModbusLifecycleHygieneTests
/// </summary>
private sealed class EmptyBitTransport : IModbusTransport
{
public TaskCompletionSource ProbeEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ProbeCancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ProbeRequestCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReleaseProbe { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public CancellationToken ProbeToken { get; private set; }
public int DisposeCount { get; private set; }
public int CoilRequestCount { get; private set; }
/// <summary>Returns a completed task without performing any connection.</summary>
/// <param name="ct">The cancellation token for the operation.</param>
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
@@ -563,10 +610,29 @@ public sealed class ModbusLifecycleHygieneTests
/// <param name="unitId">The Modbus unit ID.</param>
/// <param name="pdu">The protocol data unit being sent.</param>
/// <param name="ct">The cancellation token for the operation.</param>
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromResult(new byte[] { pdu[0], 0 });
/// <summary>Completes the disposal without doing any work.</summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] == 0x03)
{
ProbeToken = ct;
ProbeEntered.TrySetResult();
try { await ReleaseProbe.Task.WaitAsync(ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
ProbeCancelled.TrySetResult();
throw;
}
finally { ProbeRequestCompleted.TrySetResult(); }
}
if (pdu[0] == 0x01) CoilRequestCount++;
return new byte[] { pdu[0], 0 };
}
/// <summary>Records transport disposal.</summary>
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
// -------------------- Finding -003 (volatile _health) --------------------
@@ -29,4 +29,7 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
</ItemGroup>
</Project>
@@ -27,7 +27,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// areas: <b>PASSED</b> — 4 passed, 0 failed, 1 skipped (the device number-addressing test,
/// see below). The Timer/Counter batch was served by 4 requests for 7 tags: one DB1 span,
/// one 3-item multi-var carrying only the scalars, and one 2-byte area read apiece for
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (decoded count 4711).
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (surfaced raw word 4711).
/// </para>
/// <para>
/// <b>Counter evidence limit.</b> The profile's C0=4711 and C7=31337 are synthetic raw
/// words, outside classic SIMATIC counts 0..999. The device-0 record proves the fixture's
/// raw-word decode and batching exclusion, not classic count semantics or nonzero
/// addressing. BuildOptions retains S71500/rack 0/slot 0; a target-specific semantic gate
/// still needs independent PLC counts and binary/BCD references paired with captured
/// C-area bytes, CPU model/firmware and connection settings (docs/drivers/S7.md, #616).
/// </para>
/// <para>
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
@@ -73,11 +81,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// returns TM bytes 0..1, and it takes <c>start 80</c> (= 10 × 8) to reach the timer seeded
/// at TM byte 10. Device <b>0</b> is the single fixed point of the two conventions
/// (<c>0 &gt;&gt; 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
/// VALUE for, and that is what the mixed batch uses. The spec-correct <c>2 × N</c> cells
/// VALUE for, and that is what the mixed batch uses. The profile's <c>2 × N</c> cells
/// are seeded too and
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts
/// them — skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a real
/// CPU. The device-number offset arithmetic itself stays unit-covered
/// CPU. This flag is an operator declaration, not hardware evidence.
/// The device-number offset arithmetic itself stays unit-covered
/// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
/// </para>
/// <para>
@@ -240,7 +249,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good");
// (a) Correct decode off real wire. A Timer surfaces as the S5TIME word's duration in
// seconds (double) and a Counter as the raw count (int) — two different areas, two
// seconds (double) and a Counter as the raw word (int) — two different areas, two
// different values, two different CLR types, so a read that reached the wrong area or
// decoded through the wrong arm cannot pass.
byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds);
@@ -280,15 +289,19 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values —
/// the assertion the shared fixture cannot serve.
/// the raw-word assertion the shared fixture cannot serve.
/// </summary>
/// <remarks>
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a device
/// number-addressing CPU, because python-snap7's server is not one: it resolves the TM/CT
/// request address as a bit address, so <c>T5</c> reads TM byte 0 rather than TM byte 10
/// (measured 2026-08-17 — see the type remarks). Set the variable alongside
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas.
/// (measured 2026-08-17 — see the type remarks). The flag only enables this test;
/// BuildOptions still selects S71500, rack 0, slot 0 and the profile's raw C7 word 31337.
/// It does not configure a classic CPU or prove classic 0..999 count semantics. A separate
/// target-specific gate needs valid counts, independent PLC binary/BCD references and
/// captured C-area bytes, plus CPU model/firmware and connection settings (#616).
/// See docs/drivers/S7.md for the evidence checklist. The skip message below retains the
/// historical fixture instructions; enabling the flag alone does not satisfy this gate.
/// </remarks>
[Fact]
public async Task Timer_and_counter_decode_the_seeded_values_at_their_device_numbers()
@@ -408,12 +421,13 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>
/// The Timer/Counter layouts. Two sets: a mixed batch that provokes all three read shapes at
/// once (block span, multi-var, per-tag Timer/Counter), and a device-numbered pair only a
/// real CPU can serve. Addresses map 1:1 to the TM0 / CT0 seeds in
/// device number-addressing endpoint can serve. These synthetic raw-word expectations
/// do not prove classic counter semantics. Addresses map 1:1 to the TM0 / CT0 seeds in
/// <c>Docker/profiles/s7_1500.json</c>.
/// </summary>
private static class TimerCounterLayout
{
/// <summary>Declares the endpoint a real S7 CPU, which addresses TM/CT by device number.</summary>
/// <summary>Operator opt-in for nonzero TM/CT addressing; does not change CPU options or prove encoding.</summary>
public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
/// <summary>
@@ -425,7 +439,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
public const double Device0TimerSeconds = 456.0d;
/// <summary>C0's seed — the raw big-endian count word.</summary>
/// <summary>C0's synthetic raw big-endian word 4711; not a classic 0..999 count reference.</summary>
public const int Device0CounterCount = 4711;
/// <summary>
@@ -441,7 +455,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16),
new("TC_Lone_Mk", "MW10", S7DataType.UInt16),
// Timer tags are typed Float64 (decoded to seconds) and Counter tags Int32 (decoded to
// a count) — the init guard rejects any other typing. Writable:false is mandatory, not
// a raw word) — the init guard rejects any other typing. Writable:false is mandatory, not
// stylistic: S7TagDefinition defaults Writable to TRUE and the same guard rejects a
// writable Timer/Counter outright (they are read-only this phase).
new("TC_Timer", "T0", S7DataType.Float64, Writable: false),
@@ -1,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// orphaned the previous state's probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReinitializeReleasesStateTests
public sealed class TwinCATReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
@@ -21,28 +22,88 @@ public sealed class TwinCATReinitializeReleasesStateTests
Devices = [new TwinCATDeviceOptions(host)],
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and holds a connected client.
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and holds a connected client.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
await Task.Delay(10);
}
var firstClient = factory.Clients[0];
var firstLoop = first.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"TwinCAT SecondInitialize cleanup failed: {failure}"));
}
/// <summary>Failure after a live first or replacement state must release both owned resources.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
{
const string host = "ads://5.23.91.23.1.1:851";
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(host)],
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
}, "drv-reinit", factory);
Task? firstLoop = null, secondLoop = null;
FakeTwinCATClient? firstClient = null, secondClient = null;
var sentinel = new InvalidOperationException("body sentinel");
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
firstClient = factory.Clients[0];
firstLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
if (afterReplacement)
{
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Clients.Count > 1 && factory.Clients[1].IsConnected);
secondClient = factory.Clients[1];
secondLoop = drv.GetDeviceState(host)!.ProbeTask;
_ = secondLoop.ShouldNotBeNull();
secondLoop!.IsCompleted.ShouldBeFalse();
}
throw sentinel;
}, () => drv.ShutdownAsync(CancellationToken.None),
failure => output.WriteLine($"TwinCAT failure fixture cleanup failed: {failure}")));
actual.ShouldBeSameAs(sentinel);
firstLoop!.IsCompleted.ShouldBeTrue();
firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
if (afterReplacement)
{
secondLoop!.IsCompleted.ShouldBeTrue();
secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
secondClient.IsConnected.ShouldBeFalse();
}
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
await Task.Delay(10);
}
var firstClient = factory.Clients[0];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -29,4 +29,7 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\TestSupport\FixtureCleanup.cs" Link="TestSupport\FixtureCleanup.cs" />
</ItemGroup>
</Project>
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
@@ -680,7 +683,7 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
/// <summary>Counts contact loads and fails the first <c>failFirst</c> of them.</summary>
private sealed class CountingDbFactory(
IDbContextFactory<OtOpcUaConfigDbContext> inner, int failFirst = 0)
IDbContextFactory<OtOpcUaConfigDbContext> inner, int failFirst = 0, Action<int>? onLoad = null)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private int _calls;
@@ -689,7 +692,9 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
public OtOpcUaConfigDbContext CreateDbContext()
{
if (Interlocked.Increment(ref _calls) <= failFirst)
var call = Interlocked.Increment(ref _calls);
onLoad?.Invoke(call);
if (call <= failFirst)
throw new InvalidOperationException("ConfigDb unreachable at boot");
return inner.CreateDbContext();
}
@@ -747,31 +752,51 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
[Fact]
public void Bootstrap_retries_stop_once_the_gate_is_published()
{
// The provider throws on PreStart (call 1) and on the first load's resolve (call 2), which
// schedules a 1 s retry. A manual refresh then publishes while that retry is still PENDING;
// stop-on-publish must cancel it. Without the cancel the timer fires, loads again and
// republishes (loads == 3, publications == 2).
// Freeze time only for this fact: the cluster harness and every other retry test retain
// their real scheduler. gRPC mode needs neither cluster membership nor pub-sub here.
using var local = new TestKit(@"akka {
loglevel = INFO
scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit""
}");
var scheduler = (TestScheduler)local.Sys.Scheduler;
var loads = local.CreateTestProbe();
var barrier = local.CreateTestProbe();
local.Sys.EventStream.Subscribe(barrier.Ref, typeof(UnhandledMessage));
var inner = NewInMemoryDbFactory();
SeedGrpcCluster(inner, "C1", ("host-a:4053", "host-a", 4056, true, false));
var factory = new CountingDbFactory(inner);
var factory = new CountingDbFactory(inner, onLoad: n => loads.Ref.Tell(n));
var psk = new RecordingPskProvider();
var calls = 0;
// PreStart and the first contact load fail resolution. The latter registers a retry.
IMeshPskProvider? PskFactory() =>
Interlocked.Increment(ref calls) <= 2 ? throw new InvalidOperationException("race") : psk;
var actor = Sys.ActorOf(CentralCommunicationActor.Props(
var actor = local.Sys.ActorOf(CentralCommunicationActor.Props(
factory, GrpcMode(), () => null, () => new RecordingDialer(), PskFactory,
bootstrapRetryInterval: TimeSpan.FromSeconds(1)));
// A mailbox barrier also waits for PreStart to register the zero-delay periodic tick.
var marker = new object();
actor.Tell(marker);
barrier.ExpectMsg<UnhandledMessage>(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
scheduler.Advance(TimeSpan.Zero);
loads.ExpectMsg(1, TimeSpan.FromSeconds(5));
local.AwaitCondition(() => Volatile.Read(ref calls) == 2, TimeSpan.FromSeconds(5));
// Positive control: the failed first resolve happened, so a retry is pending.
AwaitCondition(() => Volatile.Read(ref calls) >= 2, TimeSpan.FromSeconds(5));
Thread.Sleep(100);
actor.Tell(new CentralCommunicationActor.RefreshContacts());
AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
// This mailbox marker is handled after the failed ContactsLoaded handler returns.
// Unlike observing the provider call alone, it proves retry registration is complete.
actor.Tell(marker);
barrier.ExpectMsg<UnhandledMessage>(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
Thread.Sleep(2500);
local.EventFilter.Info(contains: "Known-cluster gate published").ExpectOne(
TimeSpan.FromSeconds(5), () => actor.Tell(new CentralCommunicationActor.RefreshContacts()));
loads.ExpectMsg(2, TimeSpan.FromSeconds(5));
// Cross the pending retry deadline, leaving the 60-second periodic tick untouched.
// LoadContactsFromDb uses Task.Run, so observe its boundary for the original absence
// budget rather than asserting before a mistakenly retained retry can start its read.
scheduler.Advance(TimeSpan.FromMilliseconds(2500));
loads.ExpectNoMsg(TimeSpan.FromMilliseconds(2500));
factory.Calls.ShouldBe(2);
psk.KnownClusterPublications.Count.ShouldBe(1);
}
@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
public sealed class FailedServiceLevelBindingCleanupTests
{
[Fact]
public async Task Successful_cleanup_observes_stop_before_disposal_and_releases_ownership()
{
var events = new List<string>();
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
() => { events.Add("stop"); return Task.CompletedTask; },
() => { events.Add("dispose"); return ValueTask.CompletedTask; },
NullLogger.Instance);
events.ShouldBe(new[] { "stop", "dispose" });
released.ShouldBeTrue();
}
[Fact]
public async Task Failed_stop_still_disposes_and_retains_ownership_for_retry()
{
var events = new List<string>();
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
() => { events.Add("stop"); throw new InvalidOperationException("controlled stop failure"); },
() => { events.Add("dispose"); return ValueTask.CompletedTask; },
NullLogger.Instance);
events.ShouldBe(new[] { "stop", "dispose" });
released.ShouldBeFalse();
}
[Fact]
public async Task Failed_disposal_retains_ownership_without_escaping_the_binding_failure_handler()
{
var events = new List<string>();
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
() => { events.Add("stop"); return Task.CompletedTask; },
() => { events.Add("dispose"); throw new InvalidOperationException("controlled disposal failure"); },
NullLogger.Instance);
events.ShouldBe(new[] { "stop", "dispose" });
released.ShouldBeFalse();
}
}
@@ -0,0 +1,103 @@
using System.Net;
using System.Net.Sockets;
using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
public sealed class OtOpcUaServerHostedServiceBindingFailureTests
{
[Fact]
public async Task Binding_failure_closes_started_endpoint_and_allows_other_host_services_to_start()
{
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-binding-{Guid.NewGuid():N}");
var deferred = new DeferredServiceLevelPublisher();
deferred.Publish(0);
var bindingReached = false;
var listenerObserved = false;
OtOpcUaServerHostedService? service = null;
var factory = new FaultInjectingLoggerFactory(() =>
{
using var connected = new TcpClient();
connected.Connect(IPAddress.Loopback, port);
listenerObserved = connected.Connected;
bindingReached = true;
throw new InvalidOperationException("controlled binding factory failure");
});
service = new OtOpcUaServerHostedService(
Options.Create(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.BindingFailure",
ApplicationUri = $"urn:OtOpcUa.BindingFailure:{Guid.NewGuid():N}",
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = pki,
}), new DeferredAddressSpaceSink(), deferred,
NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no actor system required"), new ActorRegistry(),
null, Options.Create(new AclOptions()), TimeProvider.System, factory);
var admin = new AdminBootSentinel();
using var host = new HostBuilder().ConfigureServices(services =>
{
services.AddSingleton<IHostedService>(service);
services.AddSingleton<IHostedService>(admin);
}).Build();
try
{
await host.StartAsync(deadline.Token);
listenerObserved.ShouldBeTrue("the SDK endpoint was actually listening before replay");
bindingReached.ShouldBeTrue();
admin.Started.ShouldBeTrue();
using var client = new TcpClient();
await Should.ThrowAsync<SocketException>(async () =>
await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token));
// After construction failure, subsequent requests are accepted into the unbound cache.
deferred.Publish(100);
await host.StopAsync(deadline.Token);
}
finally
{
await service.DisposeAsync();
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
}
}
private sealed class FaultInjectingLoggerFactory(Action installFault) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName)
{
if (categoryName == typeof(SdkServiceLevelPublisher).FullName) installFault();
return NullLogger.Instance;
}
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
private sealed class AdminBootSentinel : IHostedService
{
public bool Started { get; private set; }
public Task StartAsync(CancellationToken cancellationToken)
{
Started = true;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>Local loopback evidence that late binding replaces the SDK default on the wire.</summary>
public sealed class ServiceLevelBindingReplayTests
{
[Theory]
[InlineData((byte)100)]
[InlineData((byte)0)]
public async Task Late_binding_replays_latest_request_to_standard_ServiceLevel_read(byte latest)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(15));
var ct = deadline.Token;
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-replay-{Guid.NewGuid():N}");
var clientPki = TestClientSecurity.AllocatePkiRoot();
var port = 0;
try
{
var deferred = new DeferredServiceLevelPublisher();
deferred.Publish(240);
deferred.Publish(latest);
var started = await OpcUaHostStart.StartAsync(p =>
{
port = p;
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Replay",
ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}",
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
}, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new StandardServer(), ct);
await using var host = started.Host;
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
var config = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.ReplayClient",
ApplicationUri = $"urn:OtOpcUa.ReplayClient:{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(clientPki),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
};
await config.ValidateAsync(ApplicationType.Client, ct);
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(config,
$"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)),
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay",
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null, ct: ct);
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
read.StatusCode.ShouldBe(StatusCodes.Good);
read.Value.ShouldBe(latest);
}
finally
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
}
}
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class DeferredServiceLevelPublisherTests
{
/// <summary>Verifies that publish before SetInner is a safe no-op.</summary>
/// <summary>Verifies that publish before SetInner safely accepts a cached request.</summary>
[Fact]
public void Publish_before_SetInner_is_a_safe_noop()
public void Publish_before_SetInner_accepts_a_cached_request()
{
var deferred = new DeferredServiceLevelPublisher();
@@ -28,9 +28,9 @@ public sealed class DeferredServiceLevelPublisherTests
recording.LastValue.ShouldBe((byte)200);
}
/// <summary>Verifies that SetInner with null reverts to the null publisher.</summary>
/// <summary>Verifies that SetInner with null detaches the previous publisher.</summary>
[Fact]
public void SetInner_null_reverts_to_Null_publisher()
public void SetInner_null_detaches_the_previous_publisher()
{
var recording = new RecordingPublisher();
var deferred = new DeferredServiceLevelPublisher();
@@ -19,6 +19,40 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
[Fact]
public void Missing_ServiceLevel_is_reported_as_failure()
{
var server = System.Reflection.DispatchProxy.Create<IServerInternal, FaultingServer>();
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
Should.Throw<InvalidOperationException>(() => publisher.Publish(0));
}
[Fact]
public void Sdk_write_failure_propagates_the_original_exception()
{
var server = System.Reflection.DispatchProxy.Create<IServerInternal, FaultingServer>();
var proxy = (FaultingServer)(object)server;
proxy.Node = new Opc.Ua.ServerObjectState(null)
{
ServiceLevel = new Opc.Ua.PropertyState<byte>(null),
};
proxy.Fault = new InvalidOperationException("controlled diagnostics failure");
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
Should.Throw<InvalidOperationException>(() => publisher.Publish(0)).ShouldBeSameAs(proxy.Fault);
}
public class FaultingServer : System.Reflection.DispatchProxy
{
public Opc.Ua.ServerObjectState? Node { get; set; }
public Exception? Fault { get; set; }
protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args)
{
if (method?.Name == "get_ServerObject") return Node;
if (method?.Name == "get_DiagnosticsLock") throw Fault!;
throw new NotSupportedException(method?.Name);
}
}
/// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
[Fact]
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
@@ -0,0 +1,240 @@
using System.Collections.Concurrent;
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorFreshnessTests : RuntimeActorTestBase
{
[Theory]
[InlineData(false, 240, 200)]
[InlineData(true, 250, 210)]
public void DbLess_timer_demotes_without_incoming_messages(bool primary, byte fresh, byte stale)
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("freshness-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(1), healthTickInterval: TimeSpan.FromMilliseconds(25),
utcNow: clock.Read));
actor.Tell(Snapshot(local, primary, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh }), PresenceBudget);
// Equality remains fresh. Observe several real ticks with a fixed clock; no actor Tell.
clock.Advance(TimeSpan.FromSeconds(1));
var reads = clock.Reads;
AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget);
// Drain earlier self-enqueued publications without triggering a health recomputation.
actor.Tell(new Identify("freshness-barrier"), TestActor);
ExpectMsg<ActorIdentity>().Subject.ShouldBe(actor);
publisher.Levels.ShouldBe(new[] { fresh });
clock.Advance(TimeSpan.FromTicks(1));
// No further Tell: only the production timer can notice age crossing the window.
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale }), PresenceBudget);
// A new fresh snapshot restores service, after which ticks continue to age that snapshot.
actor.Tell(Snapshot(local, primary, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale, fresh }), PresenceBudget);
clock.Advance(TimeSpan.FromSeconds(1) + TimeSpan.FromTicks(1));
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale, fresh, stale }), PresenceBudget);
}
[Fact]
public void DbLess_timer_expires_negative_peer_verdict_only_after_exact_boundary()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("peer-verdict-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(10), probeFreshnessWindow: TimeSpan.FromSeconds(2),
healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget);
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget);
clock.Advance(TimeSpan.FromSeconds(2));
var reads = clock.Reads;
AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget);
// Drain earlier self-enqueued publications without triggering a health recomputation.
actor.Tell(new Identify("freshness-barrier"), TestActor);
ExpectMsg<ActorIdentity>().Subject.ShouldBe(actor);
publisher.Levels.ShouldBe(new byte[] { 240, 0 });
clock.Advance(TimeSpan.FromTicks(1));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget);
}
[Fact]
public void Stopping_DbLess_actor_cancels_freshness_timer_and_publications()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("stopped-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(1), healthTickInterval: TimeSpan.FromMilliseconds(25),
utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget);
var reads = clock.Reads;
AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget);
Watch(actor);
Sys.Stop(actor);
ExpectTerminated(actor);
reads = clock.Reads;
clock.Advance(TimeSpan.FromSeconds(2));
ExpectNoMsg(TimeSpan.FromMilliseconds(150)); // Six timer periods after confirmed termination.
clock.Reads.ShouldBe(reads);
publisher.Levels.ShouldBe(new byte[] { 240 });
}
[Fact]
public void DbBacked_timer_refreshes_probe_status_periodically()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("db-backed-periodic-node");
var db = CreateTestProbe();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: db.Ref,
healthTickInterval: TimeSpan.FromMilliseconds(100), utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
db.Reply(new DbHealthProbeActor.DbHealthStatus(true, clock.Now, null));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), PresenceBudget);
// A second request comes from the periodic timer, not the immediate startup tick.
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
db.Reply(new DbHealthProbeActor.DbHealthStatus(false, clock.Now, "unreachable"));
AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)100), PresenceBudget);
}
[Fact]
public void DbBacked_timer_ask_timeout_demotes_last_known_healthy_node()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("db-backed-timeout-node");
var db = CreateTestProbe();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: db.Ref,
healthTickInterval: TimeSpan.FromMilliseconds(100), utcNow: clock.Read));
actor.Tell(Snapshot(local, true, clock.Now));
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
db.Reply(new DbHealthProbeActor.DbHealthStatus(true, clock.Now, null));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), PresenceBudget);
// Leave subsequent real Ask requests unanswered: the timeout status must reach the actor.
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)110), PresenceBudget);
}
[Fact]
public void Missing_local_snapshot_entry_retries_zero_on_timer_and_recovers()
{
var publisher = new RetryZeroPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("missing-entry-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget);
// Only this snapshot Tell requests the first zero. Fail it and the next attempt so
// subsequent zero attempts must come from the periodic timer, exercising the null-entry
// branch repeatedly rather than mistaking the Detached branch for missing-node coverage.
actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget);
publisher.ZeroAttempts.ShouldBeGreaterThanOrEqualTo(3);
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget);
clock.Advance(TimeSpan.FromSeconds(30) + TimeSpan.FromTicks(1));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240, 200 }), PresenceBudget);
}
[Fact]
public void No_snapshot_startup_does_not_publish_across_virtual_timer_periods()
{
// This actor requires the Cluster extension, but the absent/missing-entry guards do
// not inspect membership. Do not self-join: the ordinary harness waits for Up in its
// constructor, which cannot progress with a frozen scheduler.
using var local = new TestKit(AkkaTestHocon + @"
akka.scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit""");
var scheduler = (TestScheduler)local.Sys.Scheduler;
var publisher = new RetryZeroPublisher();
var clock = new ControlledClock();
var actor = local.ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: NodeId.Parse("no-snapshot-node"), dbLess: true,
healthTickInterval: TimeSpan.FromSeconds(1), utcNow: clock.Read));
// TestActorRef uses the calling-thread dispatcher: Advance completes delivery before
// these assertions, including the immediate startup tick and three periodic ticks.
for (var period = 0; period < 3; period++)
scheduler.Advance(TimeSpan.FromSeconds(1));
publisher.Levels.ShouldBeEmpty();
publisher.ZeroAttempts.ShouldBe(0);
clock.Reads.ShouldBe(0);
// Positive control: the same registered timer must subsequently retry a failed zero.
// No whole snapshot at startup means no publication; a received snapshot missing the
// local entry means zero. Keep these two policies explicitly distinct.
actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now));
publisher.ZeroAttempts.ShouldBe(1);
scheduler.Advance(TimeSpan.FromSeconds(1));
publisher.ZeroAttempts.ShouldBe(2);
publisher.Levels.ShouldBeEmpty();
scheduler.Advance(TimeSpan.FromSeconds(1));
publisher.ZeroAttempts.ShouldBe(3);
publisher.Levels.ShouldBe(new byte[] { 0 });
clock.Reads.ShouldBe(0);
}
private sealed class RetryZeroPublisher : IServiceLevelPublisher
{
private readonly ConcurrentQueue<byte> _levels = new();
private int _zeroAttempts;
public byte[] Levels => _levels.ToArray();
public int ZeroAttempts => Volatile.Read(ref _zeroAttempts);
public void Publish(byte serviceLevel)
{
if (serviceLevel == 0 && Interlocked.Increment(ref _zeroAttempts) <= 2)
throw new InvalidOperationException("test zero-publication failure");
_levels.Enqueue(serviceLevel);
}
}
private sealed class ControlledClock
{
private long _ticks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
private int _reads;
public DateTime Now => new(Interlocked.Read(ref _ticks), DateTimeKind.Utc);
public int Reads => Volatile.Read(ref _reads);
public DateTime Read()
{
Interlocked.Increment(ref _reads);
return Now;
}
public void Advance(TimeSpan age) => Interlocked.Add(ref _ticks, age.Ticks);
}
private static RedundancyStateChanged Snapshot(NodeId local, bool primary, DateTime asOf) =>
new(new[] { new NodeRedundancyState(local,
primary ? RedundancyRole.Primary : RedundancyRole.Secondary,
IsClusterLeader: primary, IsDriverPrimary: primary, asOf) }, CorrelationId.NewId());
private sealed class RecordingPublisher : IServiceLevelPublisher
{
private readonly ConcurrentQueue<byte> _levels = new();
public byte[] Levels => _levels.ToArray();
public void Publish(byte serviceLevel) => _levels.Enqueue(serviceLevel);
}
}
@@ -15,6 +15,49 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
{
[Fact]
public void Failed_value_is_retried_and_last_level_only_tracks_success()
{
var sink = new RecoveringPublisher { Fail = false };
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(serviceLevel: sink));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
sink.Fail = true;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0));
actor.UnderlyingActor.LastServiceLevel.ShouldBe((byte)100);
sink.Fail = false;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0));
sink.Delivered.ShouldBe(new byte[] { 100, 0 });
}
[Fact]
public void Requesting_prior_success_after_failure_updates_delivery_and_replay()
{
var sink = new RecoveringPublisher();
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(sink);
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(serviceLevel: deferred));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
sink.Fail = true;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0));
sink.Fail = false;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
sink.Delivered.ShouldBe(new byte[] { 100, 100 });
var rebound = new RecoveringPublisher();
deferred.SetInner(rebound);
rebound.Delivered.ShouldBe(new byte[] { 100 });
}
private sealed class RecoveringPublisher : IServiceLevelPublisher
{
public bool Fail { get; set; }
public List<byte> Delivered { get; } = new();
public void Publish(byte level)
{
if (Fail) throw new InvalidOperationException("controlled failure");
Delivered.Add(level);
}
}
/// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
[Fact]
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
+32
View File
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Tests;
internal static class FixtureCleanup
{
internal const string SecondaryExceptionKey = "FixtureCleanup.SecondaryException";
internal static async Task RunAsync(Func<Task> body, Func<Task> cleanup, Action<Exception> report)
{
Exception? primary = null;
try
{
await body();
}
catch (Exception failure)
{
primary = failure;
throw;
}
finally
{
try
{
await cleanup();
}
catch (Exception secondary) when (primary is not null)
{
try { primary.Data[SecondaryExceptionKey] = secondary; } catch { }
try { report(secondary); } catch { }
}
}
}
}