4.6 KiB
LmxProxy v2 Rebuild — Deviations & Key Technical Decisions
Decisions made during implementation that differ from or extend the original plan.
1. Grpc.Tools downgraded to 2.68.1
Plan specified: Grpc.Tools 2.71.0 Actual: 2.68.1 Why: protoc.exe from 2.71.0 crashes with access violation (exit code 0xC0000005) on windev (Windows 10, x64). The 2.68.1 version works reliably. How to apply: If upgrading Grpc.Tools in the future, test protoc on windev first.
2. STA Dispatch Thread replaced with Task.Run
Plan specified: Dedicated STA thread with BlockingCollection<Action> dispatch queue and Application.DoEvents() message pump for all COM operations.
Actual: Task.Run on thread pool (MTA) for all COM operations, matching the v1 pattern.
Why: The STA thread's message pump (Application.DoEvents()) between work items was insufficient — when a COM call like AdviseSupervisory was dispatched and the thread blocked waiting for the next work item, COM event callbacks (OnDataChange, OnWriteComplete) never fired because there was no active message pump during the wait. MxAccess works from MTA threads because COM marshaling handles cross-apartment calls, and events fire on their own threads.
How to apply: Do not reintroduce STA threading for MxAccess. The System.Windows.Forms reference was removed from the Host csproj.
3. TypedValue property-level _setCase tracking
Plan specified: GetValueCase() heuristic checking non-default values (e.g., if (BoolValue) return BoolValue).
Actual: Each property setter records _setCase = TypedValueCase.XxxValue, and GetValueCase() returns _setCase directly.
Why: protobuf-net code-first has no native oneof support. The heuristic approach can't distinguish "field not set" from "field set to default value" (e.g., BoolValue = false, DoubleValue = 0.0, Int32Value = 0). Since protobuf-net calls property setters during deserialization, tracking in the setter correctly identifies which field was deserialized.
How to apply: Always use GetValueCase() to determine which TypedValue field is set, never check for non-default values directly.
4. API key sent via HTTP header (DelegatingHandler)
Plan specified: API key sent in ConnectRequest.ApiKey field (request body).
Actual: API key sent as x-api-key HTTP header on every gRPC request via ApiKeyDelegatingHandler, in addition to the request body.
Why: The Host's ApiKeyInterceptor validates the x-api-key gRPC metadata header before any RPC handler executes. protobuf-net.Grpc's CreateGrpcService<T>() doesn't expose per-call metadata, so the header must be added at the HTTP transport level. A DelegatingHandler wrapping the SocketsHttpHandler adds it to all outgoing requests.
How to apply: The GrpcChannelFactory.CreateChannel() accepts an optional apiKey parameter. The LmxProxyClient passes it during channel creation in ConnectAsync.
5. v2 test deployment on port 50100
Plan specified: Port 50052 for v2 test deployment. Actual: Port 50100. Why: Ports 50049–50060 are used by MxAccess internal COM connections (established TCP pairs between the COM client and server). Port 50052 was occupied by an ephemeral MxAccess connection from the v1 service. How to apply: When deploying alongside v1, use ports above 50100 to avoid MxAccess ephemeral port range.
6. CheckApiKey validates request body key
Plan specified: Not explicitly defined — the interceptor validates the header key.
Actual: CheckApiKey RPC validates the key from the request body (request.ApiKey) against ApiKeyService, not the header key.
Why: The x-api-key header always carries the caller's valid key (for interceptor auth). The CheckApiKey RPC is designed for clients to test whether a different key is valid, so it must check the body key independently.
How to apply: ScadaGrpcService receives ApiKeyService as an optional constructor parameter.
7. Write integration tests pending
Status: 2 of 17 integration tests fail (WriteAndReadBack, WriteBatchAndWait).
Why: The OnWriteComplete COM callback from MxAccess doesn't fire within the timeout. This may be because MxAccess completes writes synchronously without invoking the callback, or the callback event subscription (OperationCompleted event) requires different wiring. The v1 code had the same pattern but may have relied on different MxAccess COM object initialization.
How to apply: Investigate whether MxAccess Write returns synchronously (check HRESULT) and bypass the callback wait if so. Alternatively, check if OperationCompleted needs explicit COM event subscription via IConnectionPoint.