Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs
T
Joseph Doherty 2671639250 fix(gateway): resolve 2026-06-18 array-write review findings
- Server-057: extend []-suffix normalization to AddItemBulk/AddBufferedItem so bulk-added
  array tags bind write-capable handles (authz check, worker bind, and registration kept
  consistent); update gateway.md + client READMEs. Tests: AddItemBulk/AddBufferedItem wiring.
- Server-058: assert []-fallback-resolved bare array names are still denied when out of
  read/write scope and that MaxWriteClassification is enforced on suffixed array registrations.
- Contracts-023/024/025: round-trip + field-19 descriptor pin for MxSparseArray; document
  MxSparseArray in docs/Contracts.md; enumerate it in the protocol-version-3 test summary.
- Tests-040: add wiring tests for the six uncovered sparse-write arms (WriteSecured, Write2,
  WriteSecured2, Write2Bulk, WriteSecuredBulk, WriteSecured2Bulk).

dotnet build + targeted tests green (184 passed).
2026-06-18 10:58:42 -04:00

808 lines
30 KiB
C#

using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Integration coverage for the single outbound choke point
/// (<see cref="GatewaySession.InvokeAsync(WorkerCommand, System.Threading.CancellationToken)"/>):
/// array <c>AddItem</c> addresses gain the writable <c>[]</c> suffix and sparse array writes are
/// expanded to whole-array values before any command reaches the worker.
/// </summary>
public sealed class GatewayArrayWriteWiringTests
{
/// <summary>
/// A bare array <c>AddItem</c> address is normalized to its writable array form on the wire,
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
[Fact]
public async Task AddItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
},
};
worker.NextReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
Kind = MxCommandKind.AddItem,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddItem = new AddItemReply { ItemHandle = 42 },
},
};
await session.InvokeAsync(command, CancellationToken.None);
Assert.NotNull(worker.LastCommand);
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddItem.ItemDefinition);
// Track the reply through the same path the gRPC service uses; the registration must carry
// the normalized address even though the public reply is tracked from a separate command copy.
MxCommand trackingCopy = new()
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
};
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
Assert.True(session.TryGetItemRegistration(1, 42, out SessionItemRegistration registration));
Assert.Equal("Obj.Arr[]", registration.TagAddress);
}
/// <summary>A bare scalar <c>AddItem</c> address is forwarded unchanged.</summary>
[Fact]
public async Task AddItem_ScalarAddress_ForwardedUnchanged()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Scalar",
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
Assert.Equal("Obj.Scalar", worker.LastCommand!.Command.AddItem.ItemDefinition);
}
/// <summary>
/// A sparse-array <see cref="WriteCommand"/> value is expanded to a full, default-filled
/// <see cref="MxArray"/> before reaching the worker; no sparse value is ever forwarded.
/// </summary>
[Fact]
public async Task Write_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 1,
Value = new MxValue { Int32Value = 7 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.Write.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A bare array <c>AddItem2</c> address is normalized to its writable array form on the wire,
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
[Fact]
public async Task AddItem2_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddItem2,
AddItem2 = new AddItem2Command
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
},
};
worker.NextReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
Kind = MxCommandKind.AddItem2,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddItem2 = new AddItem2Reply { ItemHandle = 43 },
},
};
await session.InvokeAsync(command, CancellationToken.None);
Assert.NotNull(worker.LastCommand);
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddItem2.ItemDefinition);
MxCommand trackingCopy = new()
{
Kind = MxCommandKind.AddItem2,
AddItem2 = new AddItem2Command
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
};
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
Assert.True(session.TryGetItemRegistration(1, 43, out SessionItemRegistration registration));
Assert.Equal("Obj.Arr[]", registration.TagAddress);
}
/// <summary>
/// A sparse-array entry in a <see cref="WriteBulkCommand"/> is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; no sparse value is ever
/// forwarded inside a bulk write.
/// </summary>
[Fact]
public async Task WriteBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteBulk,
WriteBulk = new WriteBulkCommand
{
ServerHandle = 1,
Entries =
{
new WriteBulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 1,
Value = new MxValue { Int32Value = 7 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteBulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A bare array address added via <c>AddItemBulk</c> is normalized to its writable array form
/// on the wire (so the worker registers a write-capable handle), while a scalar address in the
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
/// address in the <see cref="SessionItemRegistration"/>.
/// </summary>
[Fact]
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddItemBulk,
AddItemBulk = new AddItemBulkCommand
{
ServerHandle = 1,
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
// The array address gains the writable "[]" suffix; the scalar passes through unchanged.
Assert.NotNull(worker.LastCommand);
Assert.Equal(
new[] { "Obj.Arr[]", "Obj.Scalar" },
worker.LastCommand!.Command.AddItemBulk.TagAddresses);
// The real worker echoes back the (suffixed) address it bound; tracking the reply must land the
// normalized address in the registration so a later write resolves the write-capable handle.
MxCommand trackingCopy = new()
{
Kind = MxCommandKind.AddItemBulk,
AddItemBulk = new AddItemBulkCommand
{
ServerHandle = 1,
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
},
};
MxCommandReply reply = new()
{
Kind = MxCommandKind.AddItemBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 1,
ItemHandle = 50,
TagAddress = "Obj.Arr[]",
WasSuccessful = true,
},
new SubscribeResult
{
ServerHandle = 1,
ItemHandle = 51,
TagAddress = "Obj.Scalar",
WasSuccessful = true,
},
},
},
};
session.TrackCommandReply(trackingCopy, reply);
Assert.True(session.TryGetItemRegistration(1, 50, out SessionItemRegistration arrayRegistration));
Assert.Equal("Obj.Arr[]", arrayRegistration.TagAddress);
Assert.True(session.TryGetItemRegistration(1, 51, out SessionItemRegistration scalarRegistration));
Assert.Equal("Obj.Scalar", scalarRegistration.TagAddress);
}
/// <summary>
/// A sparse-array <see cref="WriteSecuredCommand"/> value is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 2,
Value = new MxValue { Int32Value = 9 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecured.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 9, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array <see cref="Write2Command"/> (timestamped) value is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.Write2,
Write2 = new Write2Command
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 3,
Value = new MxValue { Int32Value = 5 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.Write2.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 0, 5 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array <see cref="WriteSecured2Command"/> (timestamped secured) value is expanded
/// to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
/// the expander.
/// </summary>
[Fact]
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured2,
WriteSecured2 = new WriteSecured2Command
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 0,
Value = new MxValue { Int32Value = 3 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 3, 0, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array entry in a <see cref="Write2BulkCommand"/> (timestamped) is expanded to a
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.Write2Bulk,
Write2Bulk = new Write2BulkCommand
{
ServerHandle = 1,
Entries =
{
new Write2BulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 1,
Value = new MxValue { Int32Value = 11 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.Write2Bulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 11, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array entry in a <see cref="WriteSecuredBulkCommand"/> is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecuredBulk,
WriteSecuredBulk = new WriteSecuredBulkCommand
{
ServerHandle = 1,
Entries =
{
new WriteSecuredBulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 2,
Value = new MxValue { Int32Value = 13 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecuredBulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 13, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array entry in a <see cref="WriteSecured2BulkCommand"/> (timestamped secured) is
/// expanded to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
/// the expander.
/// </summary>
[Fact]
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured2Bulk,
WriteSecured2Bulk = new WriteSecured2BulkCommand
{
ServerHandle = 1,
Entries =
{
new WriteSecured2BulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 3,
Value = new MxValue { Int32Value = 17 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2Bulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 0, 17 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
[Fact]
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
},
};
worker.NextReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
Kind = MxCommandKind.AddBufferedItem,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddBufferedItem = new AddBufferedItemReply { ItemHandle = 60 },
},
};
await session.InvokeAsync(command, CancellationToken.None);
Assert.NotNull(worker.LastCommand);
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddBufferedItem.ItemDefinition);
// AddBufferedItem tracking keys off the command's ItemDefinition (the reply carries no address),
// so the tracking-path normalization must run for the registration to match the bound handle.
MxCommand trackingCopy = new()
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
};
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
Assert.True(session.TryGetItemRegistration(1, 60, out SessionItemRegistration registration));
Assert.Equal("Obj.Arr[]", registration.TagAddress);
}
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
{
GatewaySession session = new(
sessionId: "session-array-write-wiring",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-array-write-wiring",
nonce: "nonce",
clientIdentity: "client-1",
ownerKeyId: null,
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow,
addressNormalizer: CreateNormalizer());
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
private static ArrayAddressNormalizer CreateNormalizer()
{
IReadOnlyList<GalaxyObject> objects =
[
new GalaxyObject
{
GobjectId = 1,
TagName = "Obj",
ContainedName = "Obj",
Attributes =
{
new GalaxyAttribute
{
AttributeName = "Arr",
FullTagReference = "Obj.Arr[]",
IsArray = true,
},
new GalaxyAttribute
{
AttributeName = "Scalar",
FullTagReference = "Obj.Scalar",
IsArray = false,
},
},
},
];
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
};
return new ArrayAddressNormalizer(new StubGalaxyHierarchyCache(entry));
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class CapturingWorkerClient : IWorkerClient
{
/// <summary>Gets the most recent command forwarded to the worker.</summary>
public WorkerCommand? LastCommand { get; private set; }
/// <summary>Gets or sets the reply returned by the next invocation.</summary>
public WorkerCommandReply NextReply { get; set; } = new();
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-array-write-wiring";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets the worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
LastCommand = command;
return Task.FromResult(NextReply);
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}