Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efb9197a0f | |||
| 10edc44f94 | |||
| 84bfe6cd20 | |||
| 52d1373e81 | |||
| 230cef4ef0 |
@@ -62,3 +62,6 @@ lib/
|
||||
|
||||
# Subagent-driven-development git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local OPC UA validation runs; selected receipts belong under docs/plans/evidence.
|
||||
/artifacts/opcua2/
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.4.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.0.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+6
-3
@@ -306,7 +306,7 @@ and is registered the same way for every backend.
|
||||
|
||||
> **Cross-repo follow-up — a multi-tag batch `WriteLiveValues` on the gateway contract.** The drain's
|
||||
> unit of work is one gateway round-trip **per historian tag**, because
|
||||
> `ZB.MOM.WW.HistorianGateway.Client` **0.4.0** exposes no RPC that writes values for more than one tag
|
||||
> `ZB.MOM.WW.HistorianGateway.Client` **1.0.0** exposes no RPC that writes values for more than one tag
|
||||
> in a call. Bounded parallelism (`DrainConcurrency`) is therefore the ceiling: it overlaps the
|
||||
> round-trips but cannot remove them, so a wide tag set still pays `⌈tags / concurrency⌉` network
|
||||
> hops per drain pass. Collapsing that to a single hop needs a **batch/multi-tag `WriteLiveValues`** on
|
||||
@@ -314,8 +314,11 @@ and is registered the same way for every backend.
|
||||
> lands, raising `DrainBatchSize` widens each hop's payload but not the hop count — which is why the
|
||||
> concurrency window, not the batch width, is what governs drain throughput today.
|
||||
|
||||
The HistorianGateway **Client and Contracts packages are pinned together at 0.4.0**
|
||||
([historiangw#119](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119)). The removed
|
||||
The HistorianGateway **Client and Contracts packages are pinned together at 1.0.0**
|
||||
([historiangw#131](https://gitea.dohertylan.com/dohertj2/historiangw/issues/131)). The adapter maps
|
||||
`AllowUntrustedServerCertificate` directly and only for TLS: the default verifies the server, while an
|
||||
explicit opt-out applies only when `UseTls=true`. Stale certificate options are ignored on plaintext h2c.
|
||||
This follows the earlier [0.4.0 update](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119). The removed
|
||||
`ReadBlocks` RPC and `ReadBlocksAsync` wrapper were unused here. A partially imported CA PEM bundle now
|
||||
fails client creation instead of accepting a narrower certificate pin; repair the configured bundle if
|
||||
startup reports `ArgumentException` for `CaCertificatePath`.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"issue": "https://gitea.dohertylan.com/dohertj2/historiangw/issues/131",
|
||||
"consumerBase": "16111a5e",
|
||||
"opcUaSdk": "1.5.378.182 (independent of SDK2 migration)",
|
||||
"build": {
|
||||
"command": "dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release -m:1",
|
||||
"exit": 0,
|
||||
"warnings": 0,
|
||||
"errors": 0,
|
||||
"seconds": 37.33,
|
||||
"logSha256": "cb4f1ee96bf05254e5c707179c1e81d99d8267e28c5117a84afa0b0c853fa329"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"package": "ZB.MOM.WW.HistorianGateway.Client/1.0.0",
|
||||
"type": "package",
|
||||
"source": "https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json",
|
||||
"repositoryCommit": "ea9f21c850fb618767e12690924f9966f1e433ac",
|
||||
"sha256": {
|
||||
"lib/net10.0/ZB.MOM.WW.HistorianGateway.Client.dll": "b89a02e0d228ec549c4dfb755694f7f67b573a6077f06ff2781165e884fc5e12",
|
||||
"zb.mom.ww.historiangateway.client.1.0.0.nupkg": "0a3f2836727671c07c0c393e705cc9737d2ed44258213a51fb4acf0d0ab7129e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "ZB.MOM.WW.HistorianGateway.Contracts/1.0.0",
|
||||
"type": "package",
|
||||
"source": "https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json",
|
||||
"repositoryCommit": "ea9f21c850fb618767e12690924f9966f1e433ac",
|
||||
"sha256": {
|
||||
"lib/net10.0/ZB.MOM.WW.HistorianGateway.Contracts.dll": "e60d3d4650be5d251982f7847404daf1b666f61d56e2b0ccb844af8677e97743",
|
||||
"zb.mom.ww.historiangateway.contracts.1.0.0.nupkg": "83ffd12bcbbf919c973ba522560e10aa93a4cb8c686a3232030a2cfa5f00995e"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sourceOverrides": false,
|
||||
"tests": [
|
||||
{
|
||||
"project": "gateway",
|
||||
"testExit": 0,
|
||||
"executionGuardExit": 0,
|
||||
"counters": {
|
||||
"total": "529",
|
||||
"executed": "523",
|
||||
"passed": "523",
|
||||
"failed": "0",
|
||||
"error": "0",
|
||||
"timeout": "0",
|
||||
"aborted": "0",
|
||||
"inconclusive": "0",
|
||||
"passedButRunAborted": "0",
|
||||
"notRunnable": "0",
|
||||
"notExecuted": "0",
|
||||
"disconnected": "0",
|
||||
"warning": "0",
|
||||
"completed": "0",
|
||||
"inProgress": "0",
|
||||
"pending": "0"
|
||||
},
|
||||
"outcomes": {
|
||||
"Passed": 523,
|
||||
"Failed": 0,
|
||||
"NotExecuted": 6
|
||||
},
|
||||
"requiredTlsWireTests": [
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Default_tls_rejects_an_untrusted_server_before_the_rpc",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc",
|
||||
"outcome": "Passed"
|
||||
}
|
||||
],
|
||||
"trxSha256": "68086e66da0942214c0be805366b47c443f633d90f217c91e07ec685e8aea9d9"
|
||||
},
|
||||
{
|
||||
"project": "runtime",
|
||||
"testExit": 0,
|
||||
"executionGuardExit": 0,
|
||||
"counters": {
|
||||
"total": "1005",
|
||||
"executed": "1002",
|
||||
"passed": "1002",
|
||||
"failed": "0",
|
||||
"error": "0",
|
||||
"timeout": "0",
|
||||
"aborted": "0",
|
||||
"inconclusive": "0",
|
||||
"passedButRunAborted": "0",
|
||||
"notRunnable": "0",
|
||||
"notExecuted": "0",
|
||||
"disconnected": "0",
|
||||
"warning": "0",
|
||||
"completed": "0",
|
||||
"inProgress": "0",
|
||||
"pending": "0"
|
||||
},
|
||||
"outcomes": {
|
||||
"Passed": 1002,
|
||||
"Failed": 0,
|
||||
"NotExecuted": 3
|
||||
},
|
||||
"requiredTlsWireTests": [],
|
||||
"trxSha256": "c12308fd650be0646beba044a4b3301dc2aefc43097b2da667d0eb5da408fc65"
|
||||
}
|
||||
],
|
||||
"review": "Astra approved source/security mapping and corrected real-wire tests; actual published package verification complete.",
|
||||
"deploymentPerformed": false
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
{
|
||||
"issue": 807,
|
||||
"scope": "SDK1 host terminal-disposal repair; independent of SDK2 migration release acceptance.",
|
||||
"originalMaster": "52d1373e8129b7f4f0ea2e35f86a8a5d0ed1de36",
|
||||
"redSource": "84bfe6cd20285ccf136998998ef5c051917d8201",
|
||||
"greenSource": "10edc44f94afc42300518f1504091c58217a89a9",
|
||||
"redProductionEqualsOriginalMaster": true,
|
||||
"redOriginalProductionSha256": "346362c8c18b5bcdb90757f654c02c68bad44fc5875abc8b4672f4c7ac49b381",
|
||||
"greenProductionSha256": "c4934b15e695a4110e8cea8fbdef8faabad80aadeca4ccfb007b11400057ff9b",
|
||||
"resolvedSdkPackages": [
|
||||
"OPCFoundation.NetStandard.Opc.Ua.Configuration/1.5.378.182",
|
||||
"OPCFoundation.NetStandard.Opc.Ua.Core/1.5.378.182",
|
||||
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates/1.5.378.182",
|
||||
"OPCFoundation.NetStandard.Opc.Ua.Server/1.5.378.182",
|
||||
"OPCFoundation.NetStandard.Opc.Ua.Types/1.5.378.182"
|
||||
],
|
||||
"builds": [
|
||||
{
|
||||
"rawLogPath": "/tmp/opcua-sdk1-terminal-red-build.log",
|
||||
"sha256": "38f5561a0db2a2d26f8462f36e114ced057c189effcd6e87dc5a62a42c06d52e",
|
||||
"warnings": 0,
|
||||
"errors": 0
|
||||
},
|
||||
{
|
||||
"rawLogPath": "/tmp/opcua-sdk1-terminal-green-build.log",
|
||||
"sha256": "5fae318d45d9b5b8b4ba2812ef87869edf6d851e09848154d5f4a51f47ed68d2",
|
||||
"warnings": 0,
|
||||
"errors": 0
|
||||
},
|
||||
{
|
||||
"rawLogPath": "/tmp/opcua-sdk1-terminal-solution-build.log",
|
||||
"sha256": "13317ee89238730216874531fad82e3591a6d0d1b5503c8bd896dcc95c885daf",
|
||||
"warnings": 0,
|
||||
"errors": 0
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "84bfe6cd20285ccf136998998ef5c051917d8201",
|
||||
"rawDirectory": "artifacts/opcua2/84bfe6cd/sdk1-terminal-red",
|
||||
"command": [
|
||||
"dotnet",
|
||||
"test",
|
||||
"tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--no-restore",
|
||||
"--filter",
|
||||
"FullyQualifiedName~OpcUaApplicationHostDisposalTests",
|
||||
"--logger",
|
||||
"trx;LogFileName=close.trx",
|
||||
"--results-directory",
|
||||
"artifacts/opcua2/84bfe6cd/sdk1-terminal-red"
|
||||
],
|
||||
"testExit": 1,
|
||||
"guardPassed": true,
|
||||
"runnerSeconds": 3.8918902919394895,
|
||||
"counters": {
|
||||
"total": "5",
|
||||
"executed": "5",
|
||||
"passed": "0",
|
||||
"failed": "5",
|
||||
"error": "0",
|
||||
"timeout": "0",
|
||||
"aborted": "0",
|
||||
"inconclusive": "0",
|
||||
"passedButRunAborted": "0",
|
||||
"notRunnable": "0",
|
||||
"notExecuted": "0",
|
||||
"disconnected": "0",
|
||||
"warning": "0",
|
||||
"completed": "0",
|
||||
"inProgress": "0",
|
||||
"pending": "0"
|
||||
},
|
||||
"selectedResults": [
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Host_terminally_disposes_its_booted_server_once",
|
||||
"outcome": "Failed",
|
||||
"error": "Shouldly.ShouldAssertException : server.TerminalDisposals\r\n should be\r\n1\r\n but was\r\n0"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Concurrent_disposers_share_completion_and_one_cleanup_owner",
|
||||
"outcome": "Failed",
|
||||
"error": "Shouldly.ShouldAssertException : server.StopCalls\r\n should be\r\n1\r\n but was\r\n4"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Disposed_host_rejects_start_without_taking_server_ownership",
|
||||
"outcome": "Failed",
|
||||
"error": "Shouldly.ShouldAssertException : Task `host.StartAsync(server, Ct)`\r\n should throw\r\nSystem.ObjectDisposedException\r\n but did not"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Stop_failure_still_terminally_disposes_the_server",
|
||||
"outcome": "Failed",
|
||||
"error": "Shouldly.ShouldAssertException : server.TerminalDisposals\r\n should be\r\n1\r\n but was\r\n0"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Partial_startup_failure_does_not_strand_the_server",
|
||||
"outcome": "Failed",
|
||||
"error": "Shouldly.ShouldAssertException : server.TerminalDisposals\r\n should be\r\n1\r\n but was\r\n0"
|
||||
}
|
||||
],
|
||||
"rawHashes": {
|
||||
"close.trx": "ec832633adf184a07f31075ff342bb2c1b9d3f3893c518f5737dc02526f886bb",
|
||||
"run.log": "cdb7c4982d45807de53f70dfc22fad69ccda1db740db05407cef8df8b6b6d904",
|
||||
"receipt.json": "4469c071c0d178446ccfdbdca4bf11ebad43c33feb91d8a8c5f2fa891579f2dc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "10edc44f94afc42300518f1504091c58217a89a9",
|
||||
"rawDirectory": "artifacts/opcua2/10edc44f/sdk1-terminal-green",
|
||||
"command": [
|
||||
"dotnet",
|
||||
"test",
|
||||
"tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--no-restore",
|
||||
"--filter",
|
||||
"FullyQualifiedName~OpcUaApplicationHostDisposalTests",
|
||||
"--logger",
|
||||
"trx;LogFileName=close.trx",
|
||||
"--results-directory",
|
||||
"artifacts/opcua2/10edc44f/sdk1-terminal-green"
|
||||
],
|
||||
"testExit": 0,
|
||||
"guardPassed": true,
|
||||
"runnerSeconds": 3.336757709039375,
|
||||
"counters": {
|
||||
"total": "5",
|
||||
"executed": "5",
|
||||
"passed": "5",
|
||||
"failed": "0",
|
||||
"error": "0",
|
||||
"timeout": "0",
|
||||
"aborted": "0",
|
||||
"inconclusive": "0",
|
||||
"passedButRunAborted": "0",
|
||||
"notRunnable": "0",
|
||||
"notExecuted": "0",
|
||||
"disconnected": "0",
|
||||
"warning": "0",
|
||||
"completed": "0",
|
||||
"inProgress": "0",
|
||||
"pending": "0"
|
||||
},
|
||||
"selectedResults": [
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Partial_startup_failure_does_not_strand_the_server",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Concurrent_disposers_share_completion_and_one_cleanup_owner",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Host_terminally_disposes_its_booted_server_once",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Stop_failure_still_terminally_disposes_the_server",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Disposed_host_rejects_start_without_taking_server_ownership",
|
||||
"outcome": "Passed"
|
||||
}
|
||||
],
|
||||
"rawHashes": {
|
||||
"close.trx": "c44ba287659af567fcba1209c147aa5bfb6f2fc27753c0923d08b92372c007b4",
|
||||
"run.log": "7f68723461a5ac4154c247b8bdfcc6d3770a5c1c1a88016051ef492f1684c88e",
|
||||
"receipt.json": "bea02609e11dcc4569a26add9ae5836b101bf107d0346dd34514fc1d114ba525"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "10edc44f94afc42300518f1504091c58217a89a9",
|
||||
"rawDirectory": "artifacts/opcua2/10edc44f/sdk1-server-suite",
|
||||
"command": [
|
||||
"dotnet",
|
||||
"test",
|
||||
"tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--no-restore",
|
||||
"--logger",
|
||||
"trx;LogFileName=close.trx",
|
||||
"--results-directory",
|
||||
"artifacts/opcua2/10edc44f/sdk1-server-suite"
|
||||
],
|
||||
"testExit": 0,
|
||||
"guardPassed": true,
|
||||
"runnerSeconds": 119.36682745895814,
|
||||
"counters": {
|
||||
"total": "1033",
|
||||
"executed": "1032",
|
||||
"passed": "1032",
|
||||
"failed": "0",
|
||||
"error": "0",
|
||||
"timeout": "0",
|
||||
"aborted": "0",
|
||||
"inconclusive": "0",
|
||||
"passedButRunAborted": "0",
|
||||
"notRunnable": "0",
|
||||
"notExecuted": "0",
|
||||
"disconnected": "0",
|
||||
"warning": "0",
|
||||
"completed": "0",
|
||||
"inProgress": "0",
|
||||
"pending": "0"
|
||||
},
|
||||
"selectedResults": [
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.AddressSpaceComposerDeviceHostTests.Compose_resolves_driver_device_and_device_host",
|
||||
"outcome": "NotExecuted",
|
||||
"error": "v3: equipment no longer binds a driver/device \u2014 Equipment lost DriverInstanceId/DeviceId and Compose no longer takes a devices list, so EquipmentNode's DriverInstanceId/DeviceId/DeviceHost projection is always null. Equipment references raw tags via UnsTagReference. The device-endpoint merge this suite characterized now lives in Runtime.Tests DeploymentArtifactRawPathTests; the host-extraction unit is Commons.Tests DeviceConfigIntentTests."
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Host_terminally_disposes_its_booted_server_once",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.BuildSecurityPolicies_empty_input_falls_back_to_none",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostTests.StartAsync_creates_application_certificate_in_pki_own",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostQuotaTests.Configured_transport_quotas_replace_only_what_is_set",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.BuildSecurityPolicies_default_set_emits_all_three_baseline_profiles",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Partial_startup_failure_does_not_strand_the_server",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.BuildUserTokenPolicies_withholds_anonymous_when_disallowed",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.NullOpcUaUserAuthenticator_always_denies",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostQuotaTests.Configured_request_quotas_are_applied",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_anonymous_token_is_rejected_when_disallowed",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_username_success_sets_identity_and_no_validation_error",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Disposed_host_rejects_start_without_taking_server_ownership",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostServerArrayTests.ServerArray_contains_local_uri_and_configured_peers_after_start",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostQuotaTests.Default_request_quotas_are_the_former_hard_coded_values",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.StartAsync_with_only_signandencrypt_omits_None_endpoint",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Concurrent_disposers_share_completion_and_one_cleanup_owner",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostQuotaTests.Unset_transport_quotas_keep_the_sdk_defaults",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostDisposalTests.Stop_failure_still_terminally_disposes_the_server",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.StartAsync_populates_ServerConfiguration_with_all_enabled_profiles",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostTests.StartAsync_reuses_existing_certificate_on_second_boot",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_username_denial_sets_validation_error_and_no_identity",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostQuotaTests.History_read_batch_cap_default_scales_with_the_machine",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.BuildSecurityPolicies_dedupes_repeated_profiles",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_authenticator_throwing_results_in_rejection",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_null_username_treated_as_empty_string",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostImpersonationTests.HandleImpersonation_username_success_carries_roles_on_identity",
|
||||
"outcome": "Passed"
|
||||
},
|
||||
{
|
||||
"name": "ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.OpcUaApplicationHostSecurityTests.BuildUserTokenPolicies_emits_anonymous_and_username",
|
||||
"outcome": "Passed"
|
||||
}
|
||||
],
|
||||
"rawHashes": {
|
||||
"close.trx": "800ac4134f48fc901da2c3519573184337c3f9a22cc5e7a5f4859a6b586fbf70",
|
||||
"run.log": "253457c47f8801d37ce5fafc5c95cb9f5f0a9bbcead363c4ad0f5256e18fc7df",
|
||||
"receipt.json": "b0db91f846ca883c8b8d48a946021f5e99785a26c37ff1087187b83575f62c96"
|
||||
}
|
||||
}
|
||||
],
|
||||
"existingSkip": "AddressSpaceComposerDeviceHostTests.Compose_resolves_driver_device_and_device_host",
|
||||
"newSkips": 0,
|
||||
"ownership": "One caller owns detach/Stop/terminal Dispose; all Dispose callers await shared completion; Start after disposal begins is rejected.",
|
||||
"limits": [
|
||||
"Does not serialize an already-running asynchronous Start against Dispose.",
|
||||
"Does not alter immutable83ed880e baseline publications or aggregate regression oracles.",
|
||||
"Does not establish any relative RSS denominator, #799 acceptance or SDK2 release approval."
|
||||
],
|
||||
"review": "Astra approved the isolated SDK1 source/result/package gate: byte-identical original production RED; five expected REDs and five GREENs; 1032 owning-suite passes with one existing skip; all build hashes and selected security/PKI results verified. No SDK2 release or performance-baseline approval."
|
||||
}
|
||||
+3
-11
@@ -57,23 +57,15 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi
|
||||
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
|
||||
}
|
||||
|
||||
// TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
|
||||
// each of them outright when UseTls=false ("<name> is a TLS-only option and requires
|
||||
// UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
|
||||
// Certificate defaults to false, so RequireCertificateValidation was always sent as true and
|
||||
// every http:// deployment crashed at startup, even though the scheme is documented as the
|
||||
// supported way to select h2c. There is no certificate to have a posture about here.
|
||||
// Certificate settings apply only to TLS. The client rejects an explicit opt-out or a
|
||||
// CA path on h2c, where there is no certificate to validate.
|
||||
var clientOptions = new HistorianGatewayClientOptions
|
||||
{
|
||||
Endpoint = endpointUri,
|
||||
ApiKey = options.ApiKey,
|
||||
UseTls = options.UseTls,
|
||||
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
|
||||
// INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
|
||||
// (opt-in to accept a self-signed cert) is the negation of the client's
|
||||
// RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
|
||||
// pinned CaCertificatePath always verifies.
|
||||
RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
|
||||
AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
|
||||
DefaultCallTimeout = options.CallTimeout,
|
||||
LoggerFactory = loggerFactory,
|
||||
};
|
||||
|
||||
@@ -173,6 +173,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private ApplicationInstance? _application;
|
||||
private StandardServer? _server;
|
||||
private ImpersonateEventHandler? _impersonateHandler;
|
||||
private readonly object _lifetimeLock = new();
|
||||
private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _disposeStarted;
|
||||
|
||||
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
|
||||
/// <param name="options">The host configuration options.</param>
|
||||
@@ -200,16 +203,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
|
||||
{
|
||||
_server = server;
|
||||
// 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor
|
||||
// is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps
|
||||
// the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap.
|
||||
_application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
|
||||
lock (_lifetimeLock)
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ConfigSectionName = "OtOpcUa",
|
||||
};
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this);
|
||||
_server = server;
|
||||
// 1.5.378 requires telemetry on ApplicationInstance; the empty builder keeps SDK tracing separate.
|
||||
_application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ConfigSectionName = "OtOpcUa",
|
||||
};
|
||||
}
|
||||
|
||||
_ = await BuildConfigurationAsync(cancellationToken);
|
||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -657,25 +662,60 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
/// <summary>Disposes the application host and cleans up resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) == 0)
|
||||
_ = CompleteDisposalAsync();
|
||||
return new ValueTask(_disposeCompletion.Task);
|
||||
}
|
||||
|
||||
private async Task CompleteDisposalAsync()
|
||||
{
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
// An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
|
||||
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
|
||||
sessionManager.ImpersonateUser -= _impersonateHandler;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw");
|
||||
}
|
||||
_impersonateHandler = null;
|
||||
ApplicationInstance? application;
|
||||
StandardServer? server;
|
||||
ImpersonateEventHandler? handler;
|
||||
lock (_lifetimeLock)
|
||||
{
|
||||
application = _application;
|
||||
server = _server;
|
||||
handler = _impersonateHandler;
|
||||
_application = null;
|
||||
_server = null;
|
||||
_impersonateHandler = null;
|
||||
}
|
||||
|
||||
if (_application is not null)
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
// An explicit SDK stop may already have halted CurrentInstance; terminal disposal still follows.
|
||||
if (handler is not null && server?.CurrentInstance?.SessionManager is { } sessionManager)
|
||||
sessionManager.ImpersonateUser -= handler;
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
|
||||
|
||||
if (application is not null)
|
||||
{
|
||||
try { await application.StopAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// SDK1 Stop does not release the server's request queue or terminal event registrations (#807).
|
||||
try { server?.Dispose(); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: server disposal threw"); }
|
||||
}
|
||||
}
|
||||
catch (Exception error) { failure = error; }
|
||||
finally
|
||||
{
|
||||
// 1.5.378: ApplicationInstance.Stop() → StopAsync().
|
||||
try { await _application.StopAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
// All callers observe completion, including when a logger or cleanup callback throws.
|
||||
if (failure is null) _disposeCompletion.TrySetResult();
|
||||
else _disposeCompletion.TrySetException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-17
@@ -27,23 +27,6 @@ public sealed class HistorianGatewayClientAdapterTests
|
||||
/// A plaintext <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
|
||||
/// construct, using nothing but documented defaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
|
||||
/// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
|
||||
/// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
|
||||
/// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
|
||||
/// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
|
||||
/// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
|
||||
/// which the client rejects outright when <c>UseTls=false</c>
|
||||
/// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
|
||||
/// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
|
||||
/// connection that has no certificate at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Adapter_constructs_for_plaintext_h2c_endpoint()
|
||||
{
|
||||
@@ -71,6 +54,7 @@ public sealed class HistorianGatewayClientAdapterTests
|
||||
ApiKey = "histgw_x_y",
|
||||
UseTls = false,
|
||||
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
|
||||
AllowUntrustedServerCertificate = true,
|
||||
};
|
||||
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.HistorianGateway.Client;
|
||||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||
|
||||
/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
|
||||
public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc()
|
||||
{
|
||||
await using var server = await Server.StartAsync(tls: true);
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance);
|
||||
|
||||
var error = await Should.ThrowAsync<HistorianGatewayException>(
|
||||
() => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct));
|
||||
output.WriteLine($"TLS rejection: {error}");
|
||||
ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure");
|
||||
server.MetadataCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape()
|
||||
{
|
||||
await using var server = await Server.StartAsync(tls: true);
|
||||
var options = server.Options(allowUntrusted: true);
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
|
||||
|
||||
(await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
|
||||
server.MetadataCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc()
|
||||
{
|
||||
await using var server = await Server.StartAsync(tls: false);
|
||||
var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem");
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
|
||||
|
||||
(await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
|
||||
server.MetadataCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static bool ContainsTlsAuthenticationFailure(Exception error)
|
||||
{
|
||||
var pending = new Stack<Exception>();
|
||||
var seen = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
|
||||
pending.Push(error);
|
||||
while (pending.TryPop(out var current))
|
||||
{
|
||||
if (!seen.Add(current)) continue;
|
||||
if (current is AuthenticationException) return true;
|
||||
if (current.InnerException is { } inner) pending.Push(inner);
|
||||
if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class TagsService : HistorianTags.HistorianTagsBase
|
||||
{
|
||||
public int Calls;
|
||||
public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
|
||||
{
|
||||
Interlocked.Increment(ref Calls);
|
||||
return Task.FromResult(new GetTagMetadataReply
|
||||
{ Metadata = new HistorianTagMetadata { Name = request.Tag } });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Server(WebApplication app, TagsService service, string endpoint,
|
||||
RSA? key, X509Certificate2? certificate) : IAsyncDisposable
|
||||
{
|
||||
public int MetadataCalls => Volatile.Read(ref service.Calls);
|
||||
public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new()
|
||||
{
|
||||
Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null,
|
||||
CallTimeout = TimeSpan.FromSeconds(2),
|
||||
AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath,
|
||||
};
|
||||
|
||||
public static async Task<Server> StartAsync(bool tls)
|
||||
{
|
||||
RSA? key = null;
|
||||
X509Certificate2? certificate = null;
|
||||
if (tls)
|
||||
{
|
||||
key = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
|
||||
var names = new SubjectAlternativeNameBuilder();
|
||||
names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback);
|
||||
request.CertificateExtensions.Add(names.Build());
|
||||
certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
|
||||
}
|
||||
var service = new TagsService();
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen =>
|
||||
{
|
||||
listen.Protocols = HttpProtocols.Http2;
|
||||
if (certificate is not null) listen.UseHttps(certificate);
|
||||
}));
|
||||
builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
|
||||
var app = builder.Build();
|
||||
app.MapGrpcService<TagsService>();
|
||||
try
|
||||
{
|
||||
await app.StartAsync(Ct);
|
||||
return new Server(app, service, app.Urls.Single(), key, certificate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { await app.StopAsync(Ct); }
|
||||
finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>Verifies terminal server ownership on the SDK1 host (#807).</summary>
|
||||
public sealed class OpcUaApplicationHostDisposalTests : IDisposable
|
||||
{
|
||||
private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otopcua-disposal-{Guid.NewGuid():N}");
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>Normal and repeated host disposal releases the actual SDK request-queue owner once.</summary>
|
||||
[Fact]
|
||||
public async Task Host_terminally_disposes_its_booted_server_once()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
server.TerminalDisposals.ShouldBe(1);
|
||||
await host.DisposeAsync();
|
||||
server.TerminalDisposals.ShouldBe(1);
|
||||
}
|
||||
finally { await CleanupAsync(host, server); }
|
||||
}
|
||||
|
||||
/// <summary>A throwing Stop cannot bypass the separately owned terminal Dispose.</summary>
|
||||
[Fact]
|
||||
public async Task Stop_failure_still_terminally_disposes_the_server()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
server.ThrowAfterStop = true;
|
||||
try
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
server.StopCalls.ShouldBe(1);
|
||||
server.TerminalDisposals.ShouldBe(1);
|
||||
}
|
||||
finally { await CleanupAsync(host, server); }
|
||||
}
|
||||
|
||||
/// <summary>A server accepted before startup fails remains owned by the host.</summary>
|
||||
[Fact]
|
||||
public async Task Partial_startup_failure_does_not_strand_the_server()
|
||||
{
|
||||
var host = NewHost(OpcUaHostStart.AllocateFreePort());
|
||||
var server = new RecordingServer { ThrowDuringStart = true };
|
||||
try
|
||||
{
|
||||
var failure = await Should.ThrowAsync<Exception>(() => host.StartAsync(server, Ct));
|
||||
failure.ToString().ShouldContain("controlled partial startup");
|
||||
await host.DisposeAsync();
|
||||
server.TerminalDisposals.ShouldBe(1);
|
||||
}
|
||||
finally { await CleanupAsync(host, server); }
|
||||
}
|
||||
|
||||
/// <summary>Every concurrent caller waits for the one terminal cleanup owner.</summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_disposers_share_completion_and_one_cleanup_owner()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
server.BlockStop = true;
|
||||
var calls = new List<Task>();
|
||||
try
|
||||
{
|
||||
calls.Add(host.DisposeAsync().AsTask());
|
||||
await server.StopEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct);
|
||||
for (var i = 0; i < 3; i++) calls.Add(host.DisposeAsync().AsTask());
|
||||
calls.ShouldAllBe(task => !task.IsCompleted);
|
||||
server.StopCalls.ShouldBe(1);
|
||||
server.TerminalDisposals.ShouldBe(0);
|
||||
server.ReleaseStop.TrySetResult();
|
||||
await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), Ct);
|
||||
server.TerminalDisposals.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.ReleaseStop.TrySetResult();
|
||||
try { await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); }
|
||||
finally { await CleanupAsync(host, server); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Terminal disposal before startup refuses a new owner instead of silently stranding it.</summary>
|
||||
[Fact]
|
||||
public async Task Disposed_host_rejects_start_without_taking_server_ownership()
|
||||
{
|
||||
var host = NewHost(OpcUaHostStart.AllocateFreePort());
|
||||
var server = new RecordingServer();
|
||||
try
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() => host.StartAsync(server, Ct));
|
||||
server.TerminalDisposals.ShouldBe(0, "the rejected server still belongs to its caller");
|
||||
}
|
||||
finally { await CleanupAsync(host, server); }
|
||||
}
|
||||
|
||||
private Task<(OpcUaApplicationHost Host, RecordingServer Server)> BootAsync() =>
|
||||
OpcUaHostStart.StartAsync(NewHost, () => new RecordingServer(), Ct);
|
||||
|
||||
private OpcUaApplicationHost NewHost(int port) => new(new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Disposal", ApplicationUri = "urn:OtOpcUa.Disposal",
|
||||
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
private static async Task CleanupAsync(OpcUaApplicationHost host, RecordingServer server)
|
||||
{
|
||||
try { await host.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); }
|
||||
finally
|
||||
{
|
||||
// Red tests still release their test-owned server after recording the missing host disposal.
|
||||
if (server.TerminalDisposals == 0) server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingServer : StandardServer
|
||||
{
|
||||
private int _terminalDisposals;
|
||||
private int _stopCalls;
|
||||
internal int TerminalDisposals => Volatile.Read(ref _terminalDisposals);
|
||||
internal int StopCalls => Volatile.Read(ref _stopCalls);
|
||||
internal bool ThrowDuringStart { get; init; }
|
||||
internal bool ThrowAfterStop { get; set; }
|
||||
internal bool BlockStop { get; set; }
|
||||
internal TaskCompletionSource StopEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal TaskCompletionSource ReleaseStop { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
protected override void OnServerStarting(ApplicationConfiguration configuration)
|
||||
{
|
||||
base.OnServerStarting(configuration);
|
||||
if (ThrowDuringStart) throw new InvalidOperationException("controlled partial startup");
|
||||
}
|
||||
|
||||
public override async ValueTask StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _stopCalls);
|
||||
if (BlockStop)
|
||||
{
|
||||
StopEntered.TrySetResult();
|
||||
await ReleaseStop.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
await base.StopAsync(cancellationToken);
|
||||
if (ThrowAfterStop) throw new InvalidOperationException("controlled Stop failure");
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) Interlocked.Increment(ref _terminalDisposals);
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes only this test's private fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pki)) Directory.Delete(_pki, true);
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -131,13 +131,24 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
actor.Tell(Rebuild(dep));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
// EF is recorded before the first apply's NodeAdded announcement. Wait for each reply so
|
||||
// the snapshot includes every first-apply call and the second apply has actually finished.
|
||||
var first = Rebuild(dep) with { ReportOutcome = true };
|
||||
actor.Tell(first);
|
||||
var firstCompleted = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
firstCompleted.Correlation.ShouldBe(first.Correlation);
|
||||
firstCompleted.Degraded.ShouldBeFalse();
|
||||
sink.Calls.ShouldContain("EF:eq-1");
|
||||
sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild
|
||||
var callsAfterFirst = sink.Calls.Count;
|
||||
|
||||
actor.Tell(Rebuild(dep));
|
||||
Thread.Sleep(200);
|
||||
var second = Rebuild(dep) with { ReportOutcome = true };
|
||||
actor.Tell(second);
|
||||
var secondCompleted = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
secondCompleted.Correlation.ShouldBe(second.Correlation);
|
||||
secondCompleted.Degraded.ShouldBeFalse();
|
||||
// Same composition ⇒ plan IsEmpty ⇒ applier + materialise passes not run again.
|
||||
sink.Calls.Count.ShouldBe(callsAfterFirst);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
Reference in New Issue
Block a user