Compare commits

...

5 Commits

Author SHA1 Message Date
Joseph Doherty efb9197a0f docs: record reviewed SDK1 terminal disposal repair
v2-ci / unit-tests (push) Has been cancelled
2026-11-10 17:05:06 -05:00
Joseph Doherty 10edc44f94 fix: terminally dispose SDK1 server ownership once 2026-11-10 16:53:34 -05:00
Joseph Doherty 84bfe6cd20 test: cover SDK1 terminal server disposal ownership 2026-11-10 16:51:31 -05:00
Joseph Doherty 52d1373e81 test(runtime): await rebuild outcomes before asserting no-op
v2-ci / unit-tests (push) Waiting to run
2026-11-10 15:22:47 -05:00
Joseph Doherty 230cef4ef0 build: adopt HistorianGateway 1.0 TLS options
v2-ci / unit-tests (push) Has been cancelled
2026-11-10 14:40:30 -05:00
11 changed files with 861 additions and 60 deletions
+3
View File
@@ -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/
+2 -2
View File
@@ -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
View File
@@ -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."
}
@@ -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);
}
}
}
@@ -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);
@@ -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(); }
}
}
}
@@ -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);
}
}
@@ -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);