diff --git a/Directory.Build.rsp b/Directory.Build.rsp new file mode 100644 index 00000000..7633ea7a --- /dev/null +++ b/Directory.Build.rsp @@ -0,0 +1,12 @@ +# MSBuild auto-response file, applied to every build run from this repo root. +# +# Disable the MSBuild node pool. By default MSBuild leaves worker nodes resident +# after a build so the next one starts warm. Across a session of repeated +# solution-wide builds and test runs this repo accumulated 55 idle nodes holding +# ~6 GB, which is not merely untidy: the integration suites assert on timeouts, +# and memory pressure turns those into failures indistinguishable from real +# regressions (see the Workstation-GC note in Host.IntegrationTests.csproj for +# the same failure mode from a different cause). +# +# The cost is a small startup penalty per build. That is a good trade here. +-nodeReuse:false diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs index 64c86d23..8f8c13a6 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs @@ -3,6 +3,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; @@ -23,13 +24,40 @@ public sealed class DriverTypeNamesGuardTests private static readonly string[] NonFactoryDriverAssemblySuffixes = { ".Contracts", ".Addressing", ".Cli" }; + /// + /// A stand-in for factories that require one. + /// + /// + /// Every secret resolves to absent. That is deliberate and safe here: the test only needs the + /// type-name key each Register call installs, and never invokes the registered factory + /// func, so no secret is ever requested. Resolving to absent rather than to a fake value means + /// that if this assumption is ever broken, the driver fails closed. + /// + private sealed class AbsentSecretResolver : ISecretResolver + { + public static readonly AbsentSecretResolver Instance = new(); + + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult(null); + } + /// /// Build the authoritative registered-factory set by discovering every /// *DriverFactoryExtensions.Register in the deployed driver assemblies (by convention) and - /// invoking it against a fresh registry. The factory func is never invoked — we only need the - /// type-name key each Register call installs — so is passed for the - /// optional loggerFactory/other parameters. + /// invoking it against a fresh registry. The factory func is never invoked — only the type-name + /// key each Register call installs is needed. /// + /// + /// Argument construction is by parameter type, not by position. It used to pass + /// for everything after the registry on the assumption that the + /// remaining parameters were all optional. They are not: GalaxyDriverFactoryExtensions.Register + /// takes a required and guards it with + /// ArgumentNullException.ThrowIfNull, so the reflective call threw and all three parity + /// facts failed — for a reason that had nothing to do with the drift they exist to catch. + /// (OpcUaClient's equivalent parameter is optional, which is why only Galaxy tripped it.) + /// A parameter this method cannot satisfy now fails with a message naming the factory and the + /// parameter, rather than surfacing as an opaque . + /// private static IReadOnlyCollection RegisteredDriverTypeNames() { var registry = new DriverFactoryRegistry(); @@ -62,8 +90,7 @@ public sealed class DriverTypeNamesGuardTests || ps[0].ParameterType != typeof(DriverFactoryRegistry)) continue; - var args = new object?[ps.Length]; - args[0] = registry; // remaining params (optional loggerFactory, etc.) default to null + var args = ps.Select(p => ArgumentFor(p, registry, type.Name)).ToArray(); register.Invoke(null, args); invoked++; } @@ -78,6 +105,29 @@ public sealed class DriverTypeNamesGuardTests return registry.RegisteredTypes; } + /// + /// Supplies one argument for a discovered Register parameter, by type. + /// + /// The parameter to supply. + /// The registry the factories register into. + /// The declaring factory type, for the failure message. + /// The argument value to pass. + private static object? ArgumentFor(ParameterInfo parameter, DriverFactoryRegistry registry, string factoryTypeName) + { + if (parameter.ParameterType == typeof(DriverFactoryRegistry)) return registry; + if (parameter.ParameterType == typeof(ISecretResolver)) return AbsentSecretResolver.Instance; + + // Optional or nullable-reference parameters are safe to omit; the factory is documented to + // cope. Anything else is a required dependency this method does not know how to build, and + // guessing null would resurface as an opaque ArgumentNullException from inside the factory. + if (parameter.IsOptional || !parameter.ParameterType.IsValueType) return null; + + throw new InvalidOperationException( + $"{factoryTypeName}.Register requires parameter '{parameter.Name}' of type " + + $"{parameter.ParameterType.Name}, which the DriverTypeNames guard does not know how to " + + "supply. Add a case to ArgumentFor, or give the parameter a default on the factory."); + } + /// Reflect every public const string declared on . private static IReadOnlyCollection DeclaredConstantValues() => typeof(DriverTypeNames)