Compare commits

...

13 Commits

Author SHA1 Message Date
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
23 changed files with 874 additions and 94 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 # CI for the v2 branch — runs on every push + PR to the v2-akka-fuse / master
# branches. Layered into two jobs: # branches. One job restores and builds once, then tests the WHOLE solution
# build dotnet restore + build (fast feedback on compile errors) # minus the env-gated tiers (self-maintaining).
# unit-tests the WHOLE solution minus the env-gated tiers (self-maintaining)
# #
# Skips E2E (Category=E2E) and LiveIntegration (needs a real gateway/PLC/VPN) — both # 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 # are env-gated tiers with no hosted-runner fixtures. (The skip-gated *.IntegrationTests
@@ -20,38 +19,35 @@ on:
branches: [v2-akka-fuse, master] branches: [v2-akka-fuse, master]
workflow_dispatch: {} 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: env:
DOTNET_NOLOGO: "1" DOTNET_NOLOGO: "1"
DOTNET_CLI_TELEMETRY_OPTOUT: "1" DOTNET_CLI_TELEMETRY_OPTOUT: "1"
jobs: jobs:
build: unit-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 10.0.x dotnet-version: 10.0.x
# #606: code-reviews/README.md must be regenerated from the per-module findings.md # #606: regenerated review summary must remain in sync with module findings.
# files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5).
- name: code-reviews README drift check - name: code-reviews README drift check
run: python3 code-reviews/regen-readme.py --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 - 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 - name: dotnet build
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release -m:2
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
# #606: the runner's size is the context every timing-sensitive failure needs and the log never # #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). # recorded it (load average included: the two Gitea runners may be running jobs side by side).
- name: runner capacity - name: runner capacity
@@ -69,14 +65,15 @@ jobs:
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below). # are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
- name: dotnet test (whole solution, minus E2E + LiveIntegration) - name: dotnet test (whole solution, minus E2E + LiveIntegration)
# #606: Akka TestKit time dilation for the Runtime + ControlPlane actor suites (read by their # #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 # harnesses, tests/Server/*/Harness/TestTimeFactor.cs). Their 2–8 s presence budgets
# on the runner, and their 2–8 s presence budgets — sized on an idle dev box — failed in a long # — 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 # 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. # the message never comes, and absence windows get longer (stricter). Local runs stay at 1.
env: env:
AKKA_TEST_TIMEFACTOR: "3" AKKA_TEST_TIMEFACTOR: "3"
run: > 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" --filter "Category!=E2E&Category!=LiveIntegration"
--logger "trx" --results-directory artifacts/trx --logger "trx" --results-directory artifacts/trx
# 07/S-2 fail-on-skip: a whole-solution run that executed ZERO tests is a masked outage # 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 ### Counter read
**Address**: `C{n}` (e.g. `C0`, `C10`). **DataType must be `Int32`**. The OPC UA node is **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`. Counters are **read-only** this phase; a write attempt returns `BadNotWritable`.
> **Known limitation — Counter BCD encoding on S7-300/400**: `S7.Net`'s The S7comm ReadVar **C-area response encoding remains unverified on hardware**.
> `Counter.FromByteArray` returns the raw big-endian word without BCD decode. PLC instruction representations do not by themselves establish network encoding:
> On **classic S7-300/400** the C-area word is BCD-encoded (0–999), so the 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)
> surfaced value can differ from the actual count on that hardware. S7-1200/1500 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).
> 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. 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) ### 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 exit 2
fi fi
seen=$((seen + 1)) 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)" counters="$(grep -oE '<Counters[^>]*/?>' "$f" | head -1)"
ex="$(printf '%s' "$counters" | grep -oE 'executed="[0-9]+"' | grep -oE '[0-9]+' || true)" 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)" ne="$(printf '%s' "$counters" | grep -oE 'notExecuted="[0-9]+"' | grep -oE '[0-9]+' || true)"
ex="${ex:-0}" ex="${ex:-0}"
ne="${ne:-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_executed=$((total_executed + ex))
total_skipped=$((total_skipped + ne)) total_skipped=$((total_skipped + ne))
echo " $(basename "$f"): executed=${ex} 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; namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary> /// <summary>
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference /// Late-binding adapter that accepts and retains the latest requested ServiceLevel until
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve /// the SDK publisher is available. Binding synchronously replays that request, including zero.
/// 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"/>.
/// </summary> /// </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 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> /// <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 use the null publisher.</param> /// <param name="inner">The publisher implementation to use, or null to unbind.</param>
public void SetInner(IServiceLevelPublisher? inner) => public void SetInner(IServiceLevelPublisher? inner)
_inner = inner ?? NullServiceLevelPublisher.Instance; {
lock (_gate)
{
_inner = inner;
if (_latest is { } latest) _inner?.Publish(latest);
}
}
/// <inheritdoc /> /// <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 // 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); // 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 // 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 // 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 // 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); return (object)global::S7.Net.Types.Timer.FromByteArray(block);
if (addr.Area is S7Area.Counter) if (addr.Area is S7Area.Counter)
{ {
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]), // 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 // without BCD reinterpretation. This preserves the raw value, not a verified PLC count:
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain // S7comm C-area response encoding still needs independent raw bytes and PLC-side
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD // count/binary/BCD references (#616). CPU family alone does not prove that encoding.
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up. // 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); 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; _server.NodeManager.Clock = historyUpdate.Clock;
// ServiceLevel publisher needs IServerInternal — only available after Start. // ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal) try
{ {
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher( if (_server.CurrentInstance is { } serverInternal)
serverInternal, {
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>())); _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 // arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
@@ -558,10 +558,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager) try
{ {
try { sessionManager.ImpersonateUser -= _impersonateHandler; } // An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); } 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; _impersonateHandler = null;
@@ -38,8 +38,8 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
var node = _serverInternal.ServerObject?.ServiceLevel; var node = _serverInternal.ServerObject?.ServiceLevel;
if (node is null) if (node is null)
{ {
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write"); _logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable");
return; throw new InvalidOperationException("Server.ServiceLevel is unavailable.");
} }
try try
@@ -55,6 +55,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw"); _logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
throw;
} }
} }
} }
@@ -970,10 +970,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// node starting Detached/role-less (first level = 0) would be dedup'd away, leaving the SDK's // 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. // built-in default (255 = full service) standing — a degraded node wrongly advertising 255.
if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return; if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return;
_lastServiceLevel = msg.ServiceLevel;
try try
{ {
_serviceLevel.Publish(msg.ServiceLevel); _serviceLevel.Publish(msg.ServiceLevel);
_lastServiceLevel = msg.ServiceLevel;
_publishedAtLeastOnce = true; _publishedAtLeastOnce = true;
OtOpcUaTelemetry.ServiceLevelChange.Add(1, OtOpcUaTelemetry.ServiceLevelChange.Add(1,
new KeyValuePair<string, object?>("level", msg.ServiceLevel)); new KeyValuePair<string, object?>("level", msg.ServiceLevel));
@@ -981,6 +981,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
} }
catch (Exception ex) 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); _log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel);
} }
} }
@@ -10,10 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
public class DeferredServiceLevelPublisherTests public class DeferredServiceLevelPublisherTests
{ {
[Fact] [Fact]
public void Before_SetInner_Publish_is_a_noop() public void Before_SetInner_Publish_accepts_a_cached_request()
{ {
var publisher = new DeferredServiceLevelPublisher(); var publisher = new DeferredServiceLevelPublisher();
// Must not throw; the NullServiceLevelPublisher absorbs it. // Accept the request before an SDK publisher exists.
publisher.Publish(200); publisher.Publish(200);
} }
@@ -30,7 +30,7 @@ public class DeferredServiceLevelPublisherTests
} }
[Fact] [Fact]
public void SetInner_null_reverts_to_null_publisher() public void SetInner_null_detaches_the_previous_publisher()
{ {
var spy = new SpyPublisher(); var spy = new SpyPublisher();
var publisher = new DeferredServiceLevelPublisher(); var publisher = new DeferredServiceLevelPublisher();
@@ -39,8 +39,139 @@ public class DeferredServiceLevelPublisherTests
publisher.Publish(100); publisher.Publish(100);
// The spy should NOT have received the publish after revert. // The previous publisher must not receive an unbound request.
spy.LastPublished.ShouldBe((byte)0, "spy should not have been updated after revert to null"); 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 ---- // ---- test double ----
@@ -197,8 +197,6 @@ public sealed class GatewayCeilingPagingTests : IDisposable
[InlineData(L - 1, 5000u)] [InlineData(L - 1, 5000u)]
[InlineData(L, 5000u)] [InlineData(L, 5000u)]
[InlineData(L + 1, 5000u)] [InlineData(L + 1, 5000u)]
[InlineData(Ceiling - 17, 5000u)]
[InlineData(Ceiling - 16, 5000u)]
[InlineData(Ceiling + 1, 5000u)] [InlineData(Ceiling + 1, 5000u)]
[InlineData(L - 2, 0u)] [InlineData(L - 2, 0u)]
[InlineData(L - 1, 0u)] [InlineData(L - 1, 0u)]
@@ -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, /// 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, /// 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 /// 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>
/// <para> /// <para>
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a /// <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 /// 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 /// 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 /// (<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 /// are seeded too and
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts /// <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 /// 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>). /// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
/// </para> /// </para>
/// <para> /// <para>
@@ -240,7 +249,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good"); snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good");
// (a) Correct decode off real wire. A Timer surfaces as the S5TIME word's duration in // (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 // different values, two different CLR types, so a read that reached the wrong area or
// decoded through the wrong arm cannot pass. // decoded through the wrong arm cannot pass.
byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds); byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds);
@@ -280,15 +289,19 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary> /// <summary>
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values — /// 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> /// </summary>
/// <remarks> /// <remarks>
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a device /// 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 /// 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 /// 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 /// (measured 2026-08-17 — see the type remarks). The flag only enables this test;
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match /// BuildOptions still selects S71500, rack 0, slot 0 and the profile's raw C7 word 31337.
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas. /// 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> /// </remarks>
[Fact] [Fact]
public async Task Timer_and_counter_decode_the_seeded_values_at_their_device_numbers() 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> /// <summary>
/// The Timer/Counter layouts. Two sets: a mixed batch that provokes all three read shapes at /// 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 /// 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>. /// <c>Docker/profiles/s7_1500.json</c>.
/// </summary> /// </summary>
private static class TimerCounterLayout 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"; public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
/// <summary> /// <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> /// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
public const double Device0TimerSeconds = 456.0d; 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; public const int Device0CounterCount = 4711;
/// <summary> /// <summary>
@@ -441,7 +455,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16), new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16),
new("TC_Lone_Mk", "MW10", S7DataType.UInt16), new("TC_Lone_Mk", "MW10", S7DataType.UInt16),
// Timer tags are typed Float64 (decoded to seconds) and Counter tags Int32 (decoded to // 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 // stylistic: S7TagDefinition defaults Writable to TRUE and the same guard rejects a
// writable Timer/Counter outright (they are read-only this phase). // writable Timer/Counter outright (they are read-only this phase).
new("TC_Timer", "T0", S7DataType.Float64, Writable: false), new("TC_Timer", "T0", S7DataType.Float64, Writable: false),
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe; using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
@@ -680,7 +683,7 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
/// <summary>Counts contact loads and fails the first <c>failFirst</c> of them.</summary> /// <summary>Counts contact loads and fails the first <c>failFirst</c> of them.</summary>
private sealed class CountingDbFactory( private sealed class CountingDbFactory(
IDbContextFactory<OtOpcUaConfigDbContext> inner, int failFirst = 0) IDbContextFactory<OtOpcUaConfigDbContext> inner, int failFirst = 0, Action<int>? onLoad = null)
: IDbContextFactory<OtOpcUaConfigDbContext> : IDbContextFactory<OtOpcUaConfigDbContext>
{ {
private int _calls; private int _calls;
@@ -689,7 +692,9 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
public OtOpcUaConfigDbContext CreateDbContext() 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"); throw new InvalidOperationException("ConfigDb unreachable at boot");
return inner.CreateDbContext(); return inner.CreateDbContext();
} }
@@ -747,31 +752,51 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
[Fact] [Fact]
public void Bootstrap_retries_stop_once_the_gate_is_published() 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 // Freeze time only for this fact: the cluster harness and every other retry test retain
// schedules a 1 s retry. A manual refresh then publishes while that retry is still PENDING; // their real scheduler. gRPC mode needs neither cluster membership nor pub-sub here.
// stop-on-publish must cancel it. Without the cancel the timer fires, loads again and using var local = new TestKit(@"akka {
// republishes (loads == 3, publications == 2). 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(); var inner = NewInMemoryDbFactory();
SeedGrpcCluster(inner, "C1", ("host-a:4053", "host-a", 4056, true, false)); 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 psk = new RecordingPskProvider();
var calls = 0; var calls = 0;
// PreStart and the first contact load fail resolution. The latter registers a retry.
IMeshPskProvider? PskFactory() => IMeshPskProvider? PskFactory() =>
Interlocked.Increment(ref calls) <= 2 ? throw new InvalidOperationException("race") : psk; 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, factory, GrpcMode(), () => null, () => new RecordingDialer(), PskFactory,
bootstrapRetryInterval: TimeSpan.FromSeconds(1))); 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. // This mailbox marker is handled after the failed ContactsLoaded handler returns.
AwaitCondition(() => Volatile.Read(ref calls) >= 2, TimeSpan.FromSeconds(5)); // Unlike observing the provider call alone, it proves retry registration is complete.
Thread.Sleep(100); actor.Tell(marker);
actor.Tell(new CentralCommunicationActor.RefreshContacts()); barrier.ExpectMsg<UnhandledMessage>(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
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); factory.Calls.ShouldBe(2);
psk.KnownClusterPublications.Count.ShouldBe(1); 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 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] [Fact]
public void Publish_before_SetInner_is_a_safe_noop() public void Publish_before_SetInner_accepts_a_cached_request()
{ {
var deferred = new DeferredServiceLevelPublisher(); var deferred = new DeferredServiceLevelPublisher();
@@ -28,9 +28,9 @@ public sealed class DeferredServiceLevelPublisherTests
recording.LastValue.ShouldBe((byte)200); 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] [Fact]
public void SetInner_null_reverts_to_Null_publisher() public void SetInner_null_detaches_the_previous_publisher()
{ {
var recording = new RecordingPublisher(); var recording = new RecordingPublisher();
var deferred = new DeferredServiceLevelPublisher(); var deferred = new DeferredServiceLevelPublisher();
@@ -19,6 +19,40 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
Path.GetTempPath(), Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}"); $"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> /// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
[Fact] [Fact]
public async Task Publish_writes_value_to_Server_ServiceLevel_variable() public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
@@ -15,6 +15,49 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorTests : RuntimeActorTestBase 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> /// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
[Fact] [Fact]
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests() public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()