Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8828e999c4 | |||
| 4f7a07fbd3 | |||
| da2e441bb9 | |||
| c8e3e279b4 | |||
| 468f13cb56 | |||
| f71fe13826 | |||
| 6f0674296b | |||
| 19bc13bf6a | |||
| cd50db136e | |||
| eecf301200 | |||
| 76bdca90e9 | |||
| 8ff9f38bbd | |||
| 495606b9fb |
+19
-22
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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><UnitTestResult outcome="NotExecuted"/></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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-5
@@ -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 ----
|
||||||
|
|||||||
-2
@@ -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)]
|
||||||
|
|||||||
+26
-12
@@ -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 >> 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
|
/// (<c>0 >> 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),
|
||||||
|
|||||||
+39
-14
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user