fix(heidenhain): namespaced credentials, optional-role credential isolation, loop robustness (#803 review)

- Credential exfiltration (review 2): password references are scoped to
  secret:heidenhain/<name> and env:OTOPCUA_HEIDENHAIN_<NAME> only. Enforced by
  HeidenhainOptionsParser.TryParseCredentialReference (parser, probe, deploy gate) AND again at
  resolve time in HeidenhainCredentials (fail closed without consulting the store or the
  environment), so Test Connect can no longer send env:ConnectionStrings__ConfigDb or another
  subsystem's secret to an arbitrary Host. The probe also refuses an unsendable password.
- Optional-role credentials (review 6): an unresolvable/NUL/non-Latin-1/>247-byte DNC or DATA
  credential never sends a login and never fails the session; the reads needing that role are
  BadConfigurationError (capability ConfigurationError) and INSPECT telemetry continues. A
  policy refusal inside AcquireAsync is per-tag, not a session teardown.
- Start after DisposeAsync is a no-op (review 3); initial subscription push and Publish are
  ordered under one lock (review 4); JSON-null sections are normalised and the acquisition loop
  catches every fault (review 5); backoff resets only after a session published a poll batch,
  with clamped delays (review 7).

Tests: HeidenhainReviewFixTests + namespace/normalisation tests in HeidenhainConfigTests;
each was confirmed red against the reverted fix.
This commit is contained in:
Joseph Doherty
2026-11-10 23:49:48 -05:00
parent 8fb03d6a72
commit f7a3588b24
12 changed files with 700 additions and 103 deletions
@@ -67,6 +67,7 @@ public static class HeidenhainCatalog
public const string Unsupported = "Unsupported";
public const string InvalidData = "InvalidData";
public const string CommunicationLost = "CommunicationLost";
public const string ConfigurationError = "ConfigurationError";
}
/// <summary>Every fixed leaf, value leaves first then the capability strings.</summary>
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>
/// The one parser + validator of the Heidenhain <c>DriverConfig</c> document, shared by the driver factory,
/// the Test-Connect probe, the deploy gate and the AdminUI form, so none of them can disagree about what a
/// config means.
/// the Test-Connect probe, the deploy gate and the AdminUI form/save path, so none of them can disagree about
/// what a config means.
/// </summary>
public static class HeidenhainOptionsParser
{
@@ -15,6 +15,37 @@ public static class HeidenhainOptionsParser
/// <summary>Prefix of an environment-variable password reference.</summary>
public const string EnvPrefix = "env:";
/// <summary>
/// The ONLY secret-store namespace a Heidenhain password may name: <c>secret:heidenhain/&lt;name&gt;</c> (the
/// store's slash-separated, driver-scoped naming, as in <c>galaxy/&lt;inst&gt;/apikey</c>). Scoping is the
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in <c>A_LG</c> to a Host
/// the author chose, so it must never be able to name another subsystem's secret.
/// </summary>
public const string SecretNamespace = "heidenhain/";
/// <summary>
/// The ONLY environment variables a Heidenhain password may name: <c>env:OTOPCUA_HEIDENHAIN_&lt;NAME&gt;</c>
/// (exact upper-case prefix; <c>NAME</c> is <c>[A-Z0-9_]+</c>) — never <c>ConnectionStrings__*</c>,
/// <c>ServerHistorian__ApiKey</c> or any other process variable.
/// </summary>
public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
/// <summary>The password fields a Heidenhain DriverConfig/DeviceConfig may carry.</summary>
public static readonly IReadOnlyList<string> PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
/// <summary>The kind of a parsed credential reference.</summary>
public enum CredentialKind
{
/// <summary>Blank — no password.</summary>
None,
/// <summary><c>secret:heidenhain/…</c>.</summary>
Secret,
/// <summary><c>env:OTOPCUA_HEIDENHAIN_…</c>.</summary>
Environment,
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -22,7 +53,10 @@ public static class HeidenhainOptionsParser
AllowTrailingCommas = true,
};
/// <summary>Deserialises a config document. Throws <see cref="InvalidOperationException"/> on unreadable JSON.</summary>
/// <summary>
/// Deserialises a config document (null sections normalised to defaults). Throws
/// <see cref="InvalidOperationException"/> on unreadable JSON.
/// </summary>
/// <param name="json">The DriverConfig JSON (blank ⇒ defaults).</param>
/// <returns>The options.</returns>
public static HeidenhainDriverOptions Parse(string? json)
@@ -30,8 +64,8 @@ public static class HeidenhainOptionsParser
if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions();
try
{
return JsonSerializer.Deserialize<HeidenhainDriverOptions>(json, JsonOptions)
?? throw new InvalidOperationException("Heidenhain driver config deserialised to null.");
return Normalize(JsonSerializer.Deserialize<HeidenhainDriverOptions>(json, JsonOptions)
?? throw new InvalidOperationException("Heidenhain driver config deserialised to null."));
}
catch (JsonException ex)
{
@@ -39,6 +73,30 @@ public static class HeidenhainOptionsParser
}
}
/// <summary>
/// Replaces JSON-<c>null</c> sections (<c>"Access": null</c>, <c>"Polling": null</c>, …) and null list entries
/// with their defaults, so nothing downstream can dereference a null section.
/// </summary>
/// <param name="o">The options as deserialised (or constructed).</param>
/// <returns>An options instance with every section non-null.</returns>
public static HeidenhainDriverOptions Normalize(HeidenhainDriverOptions o)
{
ArgumentNullException.ThrowIfNull(o);
if (o.Access is not null && o.Polling is not null && o.Reconnect is not null && o.Devices is not null
&& o.RawTags is not null && o.Devices.All(d => d is not null) && o.RawTags.All(t => t is not null))
return o;
return new HeidenhainDriverOptions
{
Devices = (o.Devices ?? []).Where(d => d is not null).ToList(),
RawTags = (o.RawTags ?? []).Where(t => t is not null).ToList(),
TimeoutMs = o.TimeoutMs,
Access = o.Access ?? new HeidenhainAccessOptions(),
Polling = o.Polling ?? new HeidenhainPollingOptions(),
Reconnect = o.Reconnect ?? new HeidenhainReconnectOptions(),
ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
}
/// <summary>
/// Validates driver-level ranges and every password reference. Device endpoints are validated per device
/// by <see cref="ValidateDevice"/> (a driver may be authored before its devices).
@@ -48,22 +106,23 @@ public static class HeidenhainOptionsParser
public static IReadOnlyList<string> Validate(HeidenhainDriverOptions o)
{
ArgumentNullException.ThrowIfNull(o);
o = Normalize(o);
var errors = new List<string>();
if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms.");
var p = o.Polling ?? new HeidenhainPollingOptions();
var p = o.Polling;
foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) })
if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms.");
if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256.");
var r = o.Reconnect ?? new HeidenhainReconnectOptions();
var r = o.Reconnect;
if (r.InitialDelayMs is < 100 or > 600_000) errors.Add("Reconnect.InitialDelayMs must be 100–600000 ms.");
if (r.MaxDelayMs < r.InitialDelayMs || r.MaxDelayMs > 600_000)
errors.Add("Reconnect.MaxDelayMs must be between InitialDelayMs and 600000 ms.");
if (o.ProbeTimeoutSeconds is < 1 or > 60) errors.Add("ProbeTimeoutSeconds must be 1–60.");
var a = o.Access ?? new HeidenhainAccessOptions();
var a = o.Access;
CheckRef(errors, "Access.InspectPassword", a.InspectPassword);
CheckRef(errors, "Access.DncPassword", a.DncPassword);
CheckRef(errors, "Access.DataPassword", a.DataPassword);
foreach (var d in o.Devices ?? [])
foreach (var d in o.Devices)
{
var label = string.IsNullOrWhiteSpace(d.DeviceName) ? d.Host : d.DeviceName;
CheckRef(errors, $"Device '{label}' InspectPassword", d.InspectPassword);
@@ -88,24 +147,125 @@ public static class HeidenhainOptionsParser
}
/// <summary>
/// True when <paramref name="value"/> is an acceptable password field: blank, <c>secret:&lt;name&gt;</c> or
/// <c>env:&lt;VARIABLE&gt;</c>. A literal password is refused so it never lands in the config database.
/// True when <paramref name="value"/> is an acceptable password field: blank,
/// <c>secret:heidenhain/&lt;name&gt;</c> or <c>env:OTOPCUA_HEIDENHAIN_&lt;NAME&gt;</c>.
/// </summary>
/// <param name="value">The field value.</param>
/// <returns>Whether it is a reference (or blank).</returns>
public static bool IsCredentialReference(string? value)
/// <returns>Whether it is a namespaced reference (or blank).</returns>
public static bool IsCredentialReference(string? value) => TryParseCredentialReference(value, out _, out _);
/// <summary>
/// Parses a credential reference, enforcing the Heidenhain namespace — the single rule the parser, the
/// resolver (again at resolve time), the probe, the AdminUI save path and the deploy gate all apply.
/// </summary>
/// <param name="value">The field value.</param>
/// <param name="kind">The reference kind.</param>
/// <param name="name">The full secret name (incl. <c>heidenhain/</c>) or the environment variable name.</param>
/// <returns>True for blank or a namespaced reference.</returns>
public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
{
kind = CredentialKind.None;
name = "";
if (string.IsNullOrEmpty(value)) return true;
if (value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
return value.Length > SecretPrefix.Length && !string.IsNullOrWhiteSpace(value[SecretPrefix.Length..]);
if (value.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase))
return value.Length > EnvPrefix.Length && !string.IsNullOrWhiteSpace(value[EnvPrefix.Length..]);
if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
{
var secret = value[SecretPrefix.Length..];
if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
var rest = secret[SecretNamespace.Length..];
if (rest.Length is 0 or > 200) return false;
foreach (var c in rest)
if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
kind = CredentialKind.Secret;
name = secret;
return true;
}
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
{
var variable = value[EnvPrefix.Length..];
if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
var rest = variable[EnvVariablePrefix.Length..];
if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
return false;
kind = CredentialKind.Environment;
name = variable;
return true;
}
return false;
}
/// <summary>
/// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
/// namespaced references — at the top level (a device / merged config), under <c>Access</c>, and under every
/// <c>Devices[]</c> entry; keys matched case-insensitively, as the parser binds them. The messages name the
/// field, never the value. Unreadable JSON is reported, since it cannot be checked.
/// </summary>
/// <param name="json">The config JSON.</param>
/// <returns>The problems; empty when every password field is acceptable.</returns>
public static IReadOnlyList<string> DescribeCredentialProblems(string? json)
{
var problems = new List<string>();
if (string.IsNullOrWhiteSpace(json)) return problems;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
return problems;
}
using (doc)
{
if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
ScanObject(doc.RootElement, "", problems);
foreach (var p in doc.RootElement.EnumerateObject())
{
if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
ScanObject(p.Value, "Access.", problems);
if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
{
var i = 0;
foreach (var d in p.Value.EnumerateArray())
{
if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
i++;
}
}
}
}
return problems;
}
/// <summary>The one wording of the credential rule (never includes the value).</summary>
/// <param name="field">The field.</param>
/// <returns>The message.</returns>
public static string CredentialRuleMessage(string field) =>
$"{field} must be blank, 'secret:{SecretNamespace}<name>' or 'env:{EnvVariablePrefix}<NAME>' — a literal "
+ "password, or a reference outside the Heidenhain namespace, is refused.";
private static void ScanObject(JsonElement obj, string prefix, List<string> problems)
{
foreach (var p in obj.EnumerateObject())
{
if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
var ok = p.Value.ValueKind switch
{
JsonValueKind.Null => true,
JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
_ => false,
};
if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
}
}
private static void CheckRef(List<string> errors, string field, string? value)
{
if (!IsCredentialReference(value))
errors.Add($"{field} must be blank, 'secret:<name>' or 'env:<VARIABLE>' — a literal password is not stored.");
if (!IsCredentialReference(value)) errors.Add(CredentialRuleMessage(field));
}
}
@@ -2,10 +2,16 @@ using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>A password reference that cannot be resolved (or may not be resolved). Never carries the value.</summary>
public sealed class HeidenhainCredentialException(string message) : InvalidOperationException(message);
/// <summary>
/// Resolves password references on demand (each login), so plaintext is never held in options or session
/// state. Fail-closed: a <c>secret:</c> reference with no resolver, an absent secret, an unset environment
/// variable or a literal value throws — the reference text is never sent to the control as a password.
/// state. Fail-closed AT RESOLVE TIME, independently of any earlier validation: only blank,
/// <c>secret:heidenhain/…</c> and <c>env:OTOPCUA_HEIDENHAIN_…</c> are resolved — anything else (a literal, another
/// subsystem's secret, <c>ConnectionStrings__*</c>), a <c>secret:</c> with no store, an absent secret or an unset
/// variable throws <see cref="HeidenhainCredentialException"/>. The reference is resolved on this node and sent
/// in cleartext to the configured control, which is why the namespace is enforced here as well.
/// </summary>
internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<string, string?>? environment = null)
{
@@ -14,12 +20,13 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
/// <summary>The password for a role on a device (device override, then driver-level).</summary>
public async ValueTask<string> ResolveAsync(HeidenhainDriverOptions options, HeidenhainDeviceOptions device, string role, CancellationToken ct)
{
var access = options.Access ?? new HeidenhainAccessOptions();
var reference = role switch
{
"INSPECT" => device.InspectPassword ?? options.Access.InspectPassword,
"DNC" => device.DncPassword ?? options.Access.DncPassword,
"DATA" => device.DataPassword ?? options.Access.DataPassword,
_ => throw new InvalidOperationException("Unexpected LSV2 role."),
"INSPECT" => device.InspectPassword ?? access.InspectPassword,
"DNC" => device.DncPassword ?? access.DncPassword,
"DATA" => device.DataPassword ?? access.DataPassword,
_ => throw new HeidenhainCredentialException("Unexpected LSV2 role."),
};
return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
}
@@ -27,23 +34,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
/// <summary>Resolves one reference.</summary>
public async ValueTask<string> ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
{
if (string.IsNullOrEmpty(reference)) return "";
if (!HeidenhainOptionsParser.IsCredentialReference(reference))
throw new InvalidOperationException($"{role} password must be a secret: or env: reference (fail-closed).");
if (reference.StartsWith(HeidenhainOptionsParser.EnvPrefix, StringComparison.OrdinalIgnoreCase))
if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
switch (kind)
{
var name = reference[HeidenhainOptionsParser.EnvPrefix.Length..].Trim();
return _environment(name) is { Length: > 0 } value
? value
: throw new InvalidOperationException($"{role} password environment variable '{name}' is not set (fail-closed).");
case HeidenhainOptionsParser.CredentialKind.None:
return "";
case HeidenhainOptionsParser.CredentialKind.Environment:
return _environment(name) is { Length: > 0 } value
? value
: throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
default:
if (resolver is null)
throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
}
var secret = reference[HeidenhainOptionsParser.SecretPrefix.Length..].Trim();
if (resolver is null)
throw new InvalidOperationException($"{role} password references secret '{secret}' but no secret store is available (fail-closed).");
var resolved = await resolver.GetAsync(new SecretName(secret), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new InvalidOperationException($"{role} password secret '{secret}' is absent from the store (fail-closed).");
}
}
@@ -66,6 +66,11 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
private DateTime? _lastSuccessUtc;
private string? _lastError;
private int _consecutiveFailures;
private bool _disposed;
private volatile bool _pollProgress;
/// <summary>Consecutive sessions that ended without one published poll batch (test/diagnostic).</summary>
internal int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures);
public HeidenhainDeviceRuntime(
HeidenhainDeviceOptions device,
@@ -79,7 +84,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
ILogger logger)
{
_device = device;
_options = options;
_options = HeidenhainOptionsParser.Normalize(options);
_tags = tags;
_passwords = passwords;
_publish = publish;
@@ -134,7 +139,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
{
lock (_lifecycle)
{
if (_loop is not null) return;
// A Start racing (or following) DisposeAsync must never revive an orphan loop: the driver may be
// iterating a device list it captured just before re-init disposed it.
if (_disposed || _loop is not null) return;
_cts = new CancellationTokenSource();
var token = _cts.Token;
_loop = Task.Run(() => RunAsync(token), CancellationToken.None);
@@ -143,9 +150,59 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
private async Task RunAsync(CancellationToken ct)
{
// Nothing may kill this loop silently: every iteration — connect, poll, retire, backoff — is guarded,
// and an unexpected fault becomes a logged retire + backoff, never an unobserved faulted task.
while (!ct.IsCancellationRequested)
{
try
{
await RunOnceAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Heidenhain {Host} acquisition loop fault; retrying", HostName);
try { RetireAll("Acquisition loop fault: " + ex.GetType().Name); }
catch (Exception inner) { _logger.LogError(inner, "Heidenhain {Host} retire failed", HostName); }
}
if (ct.IsCancellationRequested) break;
// Backoff resets only when the last session made real progress (published a poll batch): a control
// that accepts login + version and then drops must not be re-dialled every InitialDelayMs.
if (_pollProgress) _consecutiveFailures = 0;
_consecutiveFailures++;
try
{
SetState(HeidenhainConnectionState.Backoff, null);
await Task.Delay(BackoffDelay(_consecutiveFailures), _time, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Heidenhain {Host} backoff fault", HostName);
try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
}
/// <summary>The reconnect delay for the n-th consecutive failure, clamped to sane bounds (never a hot loop).</summary>
internal TimeSpan BackoffDelay(int failures)
{
var r = _options.Reconnect ?? new HeidenhainReconnectOptions();
var initial = TimeSpan.FromMilliseconds(Math.Clamp(r.InitialDelayMs, 100, 600_000));
var max = TimeSpan.FromMilliseconds(Math.Clamp(r.MaxDelayMs, (int)initial.TotalMilliseconds, 600_000));
return ConnectionBackoff.ComputeDelay(initial, failures, max);
}
private async Task RunOnceAsync(CancellationToken ct)
{
{
HeidenhainSession? session = null;
_pollProgress = false;
try
{
SetState(HeidenhainConnectionState.Connecting, null);
@@ -161,7 +218,6 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
_groupGood.Clear();
}
_consecutiveFailures = 0;
SetState(HeidenhainConnectionState.Connected, null);
_logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model);
await PollAsync(session, generation, ct).ConfigureAwait(false);
@@ -170,7 +226,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
throw;
}
catch (Exception ex)
{
@@ -188,15 +244,6 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
{
session?.Close();
}
if (ct.IsCancellationRequested) break;
_consecutiveFailures++;
var delay = ConnectionBackoff.ComputeDelay(
TimeSpan.FromMilliseconds(_options.Reconnect.InitialDelayMs), _consecutiveFailures,
TimeSpan.FromMilliseconds(_options.Reconnect.MaxDelayMs));
SetState(HeidenhainConnectionState.Backoff, null);
try { await Task.Delay(delay, _time, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
@@ -204,9 +251,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
{
var intervals = new Dictionary<HeidenhainPollGroup, TimeSpan>
{
[HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(_options.Polling.StatusMs),
[HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(_options.Polling.CustomMs),
[HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(_options.Polling.SlowMs),
[HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)),
[HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)),
[HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)),
};
var start = _time.GetTimestamp();
var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero);
@@ -257,6 +304,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
_groupGood[group] = good;
_lastSuccessUtc = now;
_pollProgress = true;
return true;
}
}
@@ -325,6 +373,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
CancellationTokenSource? cts;
lock (_lifecycle)
{
_disposed = true;
loop = _loop;
cts = _cts;
_loop = null;
@@ -42,6 +42,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
private readonly TimeProvider _time;
private readonly SkippedTagLedger _skipped = new();
private readonly object _lifecycleLock = new();
private readonly object _publishLock = new();
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
private HeidenhainDriverOptions _options;
@@ -68,7 +69,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
_options = options;
_options = HeidenhainOptionsParser.Normalize(options);
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
_credentials = credentials;
@@ -278,13 +279,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
{
ArgumentNullException.ThrowIfNull(fullReferences);
var sub = new Subscription(Interlocked.Increment(ref _nextSubscriptionId), fullReferences.ToHashSet(StringComparer.Ordinal));
_subscriptions[sub.Id] = sub;
// Initial push of the current cache so the subscriber starts from the driver's real state.
var now = _time.GetUtcNow().UtcDateTime;
foreach (var r in sub.References)
// Register + initial push under the publication lock, so no Publish can interleave: either it ran before
// (the push carries its value) or it runs after (it reaches this subscription). The subscriber can never
// receive an older snapshot after a newer one.
lock (_publishLock)
{
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
Raise(sub, r, snapshot);
_subscriptions[sub.Id] = sub;
var now = _time.GetUtcNow().UtcDateTime;
foreach (var r in sub.References)
{
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
Raise(sub, r, snapshot);
}
}
EnsureStarted();
@@ -298,17 +304,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
return Task.CompletedTask;
}
private void Publish(string rawPath, DataValueSnapshot snapshot)
internal void Publish(string rawPath, DataValueSnapshot snapshot)
{
var changed = true;
_cache.AddOrUpdate(rawPath, snapshot, (_, old) =>
// Same lock as SubscribeAsync's register + initial push (see there). Lock order is always
// runtime publication → _publishLock; nothing takes them the other way round.
lock (_publishLock)
{
changed = old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value);
return snapshot;
});
if (!changed) return;
foreach (var sub in _subscriptions.Values)
if (sub.References.Contains(rawPath)) Raise(sub, rawPath, snapshot);
var changed = !_cache.TryGetValue(rawPath, out var old)
|| old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value);
_cache[rawPath] = snapshot;
if (!changed) return;
foreach (var sub in _subscriptions.Values)
if (sub.References.Contains(rawPath)) Raise(sub, rawPath, snapshot);
}
}
private void Raise(Subscription sub, string rawPath, DataValueSnapshot snapshot)
@@ -40,8 +40,16 @@ public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null
try
{
string password;
// Resolved only through the namespaced resolver (fail-closed at resolve time): Test Connect can never be
// used to send another subsystem's secret or environment variable to an arbitrary Host.
try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); }
catch (InvalidOperationException ex) { return new(false, ex.Message, null); }
catch (HeidenhainCredentialException ex) { return new(false, ex.Message, null); }
try { _ = Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", password); }
catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
{
return new(false, "The INSPECT password cannot be sent in an LSV2 login (NUL, non-Latin-1 or too long).", null);
}
var perCall = TimeSpan.FromMilliseconds(Math.Min(options.TimeoutMs, budget.TotalMilliseconds));
await using var client = new Lsv2Client(device.Host, device.Port, perCall);
@@ -100,13 +100,42 @@ internal sealed class HeidenhainSession : IAsyncDisposable
{
try
{
await _client.LoginAsync(role, await _passwords(role, ct).ConfigureAwait(false), ct).ConfigureAwait(false);
await LoginWithCredentialAsync(role, ct).ConfigureAwait(false);
return HeidenhainStatus.Good;
}
catch (Lsv2RefusalException e)
{
return HeidenhainStatus.FromRefusal(e.Code);
}
catch (QualityException e)
{
// An unusable optional-role credential degrades only the reads that need that role; INSPECT stays up.
return e.Status;
}
}
/// <summary>
/// Resolves the role's password and logs in. A credential problem — an unresolvable reference, or a
/// password the policy-checked payload cannot carry (NUL, non-Latin-1, &gt; 247 bytes) — becomes
/// <see cref="HeidenhainStatus.BadConfigurationError"/> (never sent, never a session failure). Refusals and
/// transport failures propagate unchanged.
/// </summary>
private async Task LoginWithCredentialAsync(string role, CancellationToken ct)
{
string password;
try { password = await _passwords(role, ct).ConfigureAwait(false); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError);
}
try { _ = Lsv2ReadOnlyPolicy.LoginPayload(role, password); }
catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
{
throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError);
}
await _client.LoginAsync(role, password, ct).ConfigureAwait(false);
}
/// <summary>
@@ -269,6 +298,12 @@ internal sealed class HeidenhainSession : IAsyncDisposable
_client.Close();
return new Outcome(null, HeidenhainStatus.BadNoCommunication);
}
catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
{
// A request the read-only policy refuses is never sent and never ends the session: this tag only.
if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.ConfigurationError;
return new Outcome(null, HeidenhainStatus.BadConfigurationError);
}
}
private async Task<long> TimerAsync(string feature, CancellationToken ct)
@@ -330,14 +365,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
{
await WireAsync(wire, "Login_" + role, async c =>
{
string password;
try { password = await _passwords(role, c).ConfigureAwait(false); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new QualityException(HeidenhainStatus.BadUnexpectedError, HeidenhainCatalog.States.Unknown);
}
await _client.LoginAsync(role, password, c).ConfigureAwait(false);
await LoginWithCredentialAsync(role, c).ConfigureAwait(false);
return [];
}, ct).ConfigureAwait(false);
if (dnc) _dncStatus = HeidenhainStatus.Good;
@@ -349,6 +377,13 @@ internal sealed class HeidenhainSession : IAsyncDisposable
else _dataStatus = HeidenhainStatus.FromRefusal(e.Code);
throw;
}
catch (QualityException e)
{
// Credential problem: stable for this session (a fixed secret/variable is picked up on reconnect).
if (dnc) _dncStatus = e.Status;
else _dataStatus = e.Status;
throw;
}
}
private async Task PrepareTelemetryAsync(Dictionary<string, Raw> wire, CancellationToken ct)
@@ -29,6 +29,12 @@ public static class HeidenhainStatus
/// <summary>Any other controller refusal (retried next poll).</summary>
public const uint BadUnexpectedError = 0x80010000u;
/// <summary>
/// An optional-role (DNC/DATA) credential cannot be used — unresolvable reference, or a password the login
/// payload cannot carry (NUL, non-Latin-1, too long). Degrades only the reads that need that role.
/// </summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>A RawPath this driver has no authored tag for.</summary>
public const uint BadNodeIdUnknown = 0x80340000u;
@@ -48,7 +54,7 @@ public static class HeidenhainStatus
/// <summary>True for the stable refusals that may be suppressed for the rest of a session.</summary>
/// <param name="status">The status.</param>
/// <returns>Whether it is stable.</returns>
public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied;
public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied or BadConfigurationError;
/// <summary>The capability state for a failure status.</summary>
/// <param name="status">The status.</param>
@@ -59,6 +65,7 @@ public static class HeidenhainStatus
BadNotSupported => HeidenhainCatalog.States.Unsupported,
BadDecodingError or BadTypeMismatch or BadOutOfRange => HeidenhainCatalog.States.InvalidData,
BadNoCommunication => HeidenhainCatalog.States.CommunicationLost,
BadConfigurationError => HeidenhainCatalog.States.ConfigurationError,
_ => HeidenhainCatalog.States.Unknown,
};
}