Skip to content

Lifecycle & Memory Safety

Rules for Thing references, shared resources, and connection lifecycle.

Part of compliance-rules.


RULE-002: No Direct Thing References Across Async

Severity: Critical

Description: Framework services (schedulers, callbacks, long-lived closures) must not hold direct Thing references. Store Guid IDs and resolve from DeviceTreeRegistry at execution time. Direct references prevent garbage collection when Things are removed/reconnected.

Short-lived command closures that use the official Thing.ExecuteCommand(...) pattern are allowed. They are scoped to the command task and are released on command completion/cancellation with the owning Thing tree. Do not report those as RULE-002 unless the closure is retained by a long-lived root such as a scheduler, timer, singleton/shared resource, static field, persistent event subscription, or a pending task that is not cancelled by Thing/protocol teardown.

Detection pattern:

  • Fields of type Thing or subclass in services, schedulers, or singletons
  • Closures capturing Thing instances in long-lived async callbacks, timers, schedulers, subscriptions, or queues that outlive Thing/protocol teardown
  • Dictionary<*, Thing> or List<Thing> in long-lived objects
  • Do not flag ExecuteCommand(...) command-handler callbacks solely because they capture the command target Thing or its parent for the duration of the command

Correct pattern:

// Store ID, resolve at execution time
private readonly Guid _thingId;

public async Task Execute(DeviceTreeRegistry registry, CancellationToken ct)
{
    var thing = registry.Resolve(_thingId);
    if (thing is null) return; // Thing was removed
    // ... use thing
}

Violation example:

// WRONG: holds reference across async boundary
private readonly PanelDevice _panel;

public async Task DoWork()
{
    await Task.Delay(5000);
    await _panel.SomeMethod(); // _panel may be stale
}

Allowed example:

// OK: framework command supervision pattern; closure lifetime is the command task.
return door.ExecuteCommand(
    DoorState.Unsecured,
    async () => await protocol.SendCommand(module.Address, 2, ct),
    protocol,
    e => e.Address == module.Address && e.EventType == AcmeEventType.StrikeReleased,
    onSuccess: async e => await door.UnsecuredRemote(e.Timestamp, command.OperatorIdentity),
    ct: ct);


RULE-017: Shared Resource Lifecycle Must Be Complete

Severity: High

Description: ISharedResource implementations (SDK processes, connection pools, gRPC channels shared across multiple Things) must implement complete lifecycle:

  1. Start() - initialization + wait for ready signal with timeout
  2. Stop() - graceful shutdown with timeout, kill fallback
  3. External processes must wait for "ready" signal before Start returns

Why this matters:

  • Without ready check: Things use resource before initialization completes → crashes, races, lost data
  • Without timeout: hung process blocks adapter startup indefinitely
  • Without Stop: zombie processes, leaked sockets, file handles
  • Framework starts Things only after Start() returns - timing matters

Detection pattern:

  • ISharedResource implementation missing Start() or Stop()
  • Start() returns immediately without waiting for resource readiness
  • External Process.Start() without subsequent ready check (port probe, log scan, IPC handshake)
  • Stop() without timeout (or no kill fallback when graceful shutdown fails)
  • No cancellation token propagation in lifecycle methods

Correct pattern:

internal sealed class VendorSdkProcess : ISharedResource
{
    private Process? _process;

    public async Task Start(CancellationToken ct)
    {
        _process = Process.Start(new ProcessStartInfo
        {
            FileName = "vendor-sdk.exe",
            RedirectStandardOutput = true,
        });

        // Wait for ready signal with timeout
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeout.CancelAfter(TimeSpan.FromSeconds(30));

        await WaitForReady(_process, timeout.Token);
    }

    public async Task Stop(CancellationToken ct)
    {
        if (_process is null) return;

        // Graceful shutdown
        _process.StandardInput.WriteLine("shutdown");

        // Timeout + kill fallback
        if (!_process.WaitForExit(5000))
            _process.Kill(entireProcessTree: true);

        await _process.WaitForExitAsync(ct);
        _process.Dispose();
    }

    private static async Task WaitForReady(Process process, CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var line = await process.StandardOutput.ReadLineAsync(ct);
            if (line?.Contains("READY") == true) return;
        }
        throw new OperationCanceledException("SDK process did not signal ready");
    }
}

Violation examples:

// WRONG #1: no ready check
public Task Start(CancellationToken ct)
{
    _process = Process.Start("vendor-sdk.exe");
    return Task.CompletedTask;  // Things may use SDK before it's initialized
}

// WRONG #2: no timeout, hangs forever
public async Task Start(CancellationToken ct)
{
    _process = Process.Start("vendor-sdk.exe");
    while (!await IsReady())  // no timeout, no CT propagation
        await Task.Delay(100);
}

// WRONG #3: no kill fallback
public async Task Stop(CancellationToken ct)
{
    _process?.CloseMainWindow();
    await _process.WaitForExitAsync(ct);  // hangs if process ignores close signal
}

Framework responsibility:

  • SharedResourceManager calls Start() before any Thing that depends on the resource
  • Stop() called during adapter shutdown after all dependent Things stop
  • Failure in Start() propagates as adapter startup failure - log clearly

Verification:

  • Kill SDK process externally → adapter detects failure, retries via reconnect logic
  • Cancel adapter during startup → Start returns within timeout, no leaked process
  • Restart adapter multiple times → no zombie processes, no port conflicts

RULE-018: Connection Must Be Fully Ready Before Returning True

Severity: Critical

Description: IDeviceConnection.Connect() must return true ONLY after the connection is fully usable. Premature true causes framework to start polling, commands, and event subscriptions against an unready connection - resulting in lost commands, dropped events, and false-positive status reports.

What "fully ready" means:

  1. TCP/transport connected
  2. Authentication completed
  3. Protocol initialized (handshake, version exchange, capability negotiation)
  4. Address binding/registration done (if protocol requires)
  5. Event stream/polling readiness ensured (subscriptions registered, queues drained)

Failure semantics:

  • Any step fails → return false → framework retries with exponential backoff
  • Return true only when ALL steps succeed
  • Do NOT call SignalConnected() from IDeviceConnection - framework owns that

Detection pattern:

  • Connect() returns true after TCP connect without authentication
  • Missing protocol initialization (handshake, login response check) before true return
  • Authentication failure swallowed (catch + return true)
  • SignalConnected() called inside IDeviceConnection.Connect() implementation
  • Polling/event subscription not verified before returning true

Correct pattern:

public async Task<bool> Connect(CancellationToken ct)
{
    try
    {
        // 1. Transport
        await _client.ConnectAsync(_panel.IpAddress, _panel.Port, ct);

        // 2. Authentication
        if (!await Authenticate(_panel.Password, ct))
        {
            _logger.LogWarning("Authentication failed for panel {Panel}", _panel.IpAddress);
            return false;
        }

        // 3. Protocol initialization
        var version = await _protocol.GetVersion(ct);
        if (!version.IsSupported)
        {
            _logger.LogError("Unsupported panel version: {Version}", version);
            return false;
        }

        // 4. Address binding (if needed)
        await BindRequiredAddresses(ct);

        // 5. Event subscription
        await _protocol.SubscribeEvents(ct);

        return true;  // fully ready
    }
    catch (OperationCanceledException)
    {
        throw;  // let CT propagate
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Connect failed, will retry");
        return false;  // framework retries with backoff
    }
}

Violation examples:

// WRONG #1: returns true after TCP, before authentication
public async Task<bool> Connect(CancellationToken ct)
{
    await _client.ConnectAsync(_panel.IpAddress, _panel.Port, ct);
    return true;  // framework starts polling, but we're not authenticated yet
}

// WRONG #2: swallows authentication failure
public async Task<bool> Connect(CancellationToken ct)
{
    await _client.ConnectAsync(_panel.IpAddress, _panel.Port, ct);
    try { await Authenticate(ct); } catch { /* ignored */ }
    return true;  // claims success even on auth failure
}

// WRONG #3: missing protocol initialization
public async Task<bool> Connect(CancellationToken ct)
{
    await _client.ConnectAsync(ct);
    await Authenticate(ct);
    return true;  // no handshake, version check, or event subscription
}

// WRONG #4: calling SignalConnected in IDeviceConnection
public async Task<bool> Connect(CancellationToken ct)
{
    await DoConnect(ct);
    _connectionFunction.SignalConnected();  // framework does this, not adapter
    return true;
}

Framework guarantees:

  • After Connect() returns true: framework calls OnConnected() on IConnectionAware Things in subtree
  • StatusPoll, HistoryPoll, TimeSync schedulers activate for this Thing
  • Commands routed to this Thing become eligible for dispatch
  • Event interceptor active for WaitForEvent patterns

Verification:

  • Disconnect device mid-connect → next Connect() retries from step 1
  • Wrong credentials → returns false, framework retries (eventually backs off)
  • Network glitch during handshake → returns false, no partial state leaked
  • Connect returns true → first command/poll immediately succeeds

RULE-052: Time Synchronization Must Be Enabled When the Protocol Can Set the Device Clock

Severity: High

Description: When the vendor protocol documents a set-clock/set-time command, the adapter must wire time synchronization completely. Device clocks drift; an unsynced device stamps history events with wrong timestamps, which corrupts the audit trail (RULE-008 depends on device timestamps being trustworthy).

Time sync has two mandatory halves, and each fails differently when missing:

  1. capabilities.time_sync: true in adapter-registration.yaml — without it the generator never adds the TimeSynchronization function to the root device type. Silent failure: compiles clean, the device clock is simply never synced.
  2. Capability.ITimeSynchronized implemented on the root Thing's partial — TimeSynchronizationFunction casts its owner to the interface in its constructor, so a missing implementation with the capability enabled fails loudly at startup.

The dangerous half is #1: an adapter that implements ITimeSynchronized but omits the capability flag looks complete in source and never syncs.

Detection pattern:

  • Protocol layer (Protocol*.cs, vendor docs) documents a clock-set command, but adapter-registration.yaml has no capabilities.time_sync: true — violation.
  • ITimeSynchronized implemented anywhere without the capability flag (or vice versa) — violation; the two must appear together.
  • Clock-set protocol method exists but is called from nowhere except command handlers — the periodic sync path is missing.

Correct pattern:

# adapter-registration.yaml
capabilities:
  time_sync: true
public partial class AcmePanel : Capability.ITimeSynchronized
{
    /// <inheritdoc/>
    public Task SynchronizeTime(TimeProvider timeProvider) =>
        Protocol.SetClock(timeProvider.GetLocalNow());
}

Acceptable exception (must be documented): No time sync is acceptable only when the protocol exposes no clock-set command, or the device manages its own clock (e.g. NTP) — documented in the adapter's design-notes document under Known Implementation Gaps (or an equivalent design note).


RULE-057: Set the Device Clock From the Wall Clock, Never a Re-Projected Local Time

Severity: High

Description: SynchronizeTime(TimeProvider timeProvider) receives a timezone-aware provider whose LocalTimeZone is the device's configured zone (TimeSyncScheduler builds it from the TimeSynchronizationFunction.Timezone property). timeProvider.GetLocalNow() therefore returns a DateTimeOffset whose components already carry the correct device wall clock, e.g. 11:22 +02:00.

The trap is converting that DateTimeOffset to a DateTime with .LocalDateTime: that property re-projects the instant onto the host process zone — which in a container is UTC — yielding 09:22. The panel is a wall-clock device (no timezone concept), so it gets set off by the container↔device offset, and every event it stamps afterward is shifted by that offset. The failure is silent: it compiles, syncs "successfully", and only surfaces as audit timestamps that are hours wrong.

DateTimeOffset.DateTime would give the right value but is banned by analyzer policy because it drops Kind.

The fix is to keep the offset, not to convert it. Make the protocol-layer set-clock method accept a DateTimeOffset and read its components (.Hour, .Day, .Month, .Year, .Offset, …) — those are already in the device zone. Then SynchronizeTime is a one-liner that passes GetLocalNow() straight through.

Detection pattern:

  • GetLocalNow().LocalDateTime anywhere in a SynchronizeTime / set-clock path — violation.
  • A set-clock protocol method typed DateTime fed from a TimeProvider — smell; retype the boundary DateTimeOffset.
  • GetLocalNow() passed through TimeZoneInfo.ConvertTimeFromUtc(...), new DateTime(now.Year, …), or any other zone math on the way to the wire — flag; the only sanctioned form is passing GetLocalNow() straight through.
  • A time-sync or device-event-timestamp path reaching for TimeProvider.System (or a TimeProvider field defaulted to it) instead of the zone-aware provider the framework hands to SynchronizeTime — flag; it stamps the host zone, not the device zone. For an event that arrives with no device timestamp, use DeviceTimestamp.UtcNow (absolute receipt time), never a fabricated wall clock.

Correct pattern:

// Thing
public Task SynchronizeTime(TimeProvider timeProvider) =>
    Protocol.SetClock(timeProvider.GetLocalNow());   // pass the offset straight through

// Protocol — consumes the offset, reads components in the device zone
public Task SetClock(DateTimeOffset time) =>
    SendClockFrame(time.Year, time.Month, time.Day, time.Hour, time.Minute, time.Second);

timeProvider.GetLocalNow() is the only value that may reach the wire. Any transformation of it raises a flag.LocalDateTime and .DateTime are the two traps above, but TimeZoneInfo.ConvertTimeFromUtc(...), new DateTime(now.Year, now.Month, …), or any other hand-rolled zone math is equally a signal that a DateTime-typed boundary is being fought instead of fixed. Retype the boundary to DateTimeOffset; do not launder the value. (Reading the device clock back for a drift check is different: wrap the device's own naive reading — new DateTimeOffset(deviceReading, now.Offset) — and compare it to the untransformed now.)

Violation example:

// WRONG: .LocalDateTime re-projects 11:22 +02:00 onto the container zone (UTC) → 09:22
public Task SynchronizeTime(TimeProvider timeProvider)
{
    var localNow = DateTime.SpecifyKind(timeProvider.GetLocalNow().LocalDateTime, DateTimeKind.Unspecified);
    return Protocol.SetTime(localNow);   // panel clock ends up off by the container↔device offset
}

The build enforces the first detection clause as PQC257: a GetLocalNow() call whose result is read through .LocalDateTime is reported on every build, local and CI. The other clauses — zone math on the way to the wire, a DateTime-typed set-clock boundary, a path reaching for TimeProvider.System — stay with review.

Verification:

  • Run the adapter in a UTC container against a device in a non-UTC zone; the device clock must match the device wall time, not container UTC.
  • The zone used to set the clock (this rule) must equal the zone used to read event wall clocks back (EventBuilderExtensions.At resolves it from the same Timezone); a mismatch here is exactly what shifts audit timestamps.