Compare commits

..

25 Commits

Author SHA1 Message Date
Joseph Doherty 91d8715c74 docs: write-completion correlation covers all four unary write kinds
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 1m51s
ci / java (push) Successful in 2m16s
ci / portable (push) Successful in 8m26s
2026-08-09 19:47:04 -04:00
Joseph Doherty 794c44246a chore(clients): regenerate Go/Java bindings and Rust vendored proto for the plain-write statuses comments 2026-08-09 19:47:04 -04:00
Joseph Doherty b0e65d4f31 feat(worker): correlate OnWriteComplete onto plain Write/Write2 replies (06/S-1 follow-up)
OtOpcUa's dominant FreeAccess write path goes out as MX_COMMAND_KIND_WRITE,
not WriteSecured — the original 06/S-1 brief mis-scoped the correlation, so
a refused plain write was invisible on the unary reply (verified live on
windev 2026-08-09). ExecuteWrite/ExecuteWrite2 now use the same pre-call
version baseline + bounded pump-wait as the secured kinds. Bulk writes stay
fire-and-forget.
2026-08-09 19:47:00 -04:00
Joseph Doherty b948e6975e feat: correlate OnWriteComplete onto WriteSecured/WriteSecured2 unary replies (OtOpcUa 06/S-1)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 1m9s
ci / java (push) Successful in 2m3s
ci / portable (push) Successful in 7m20s
2026-08-09 12:52:18 -04:00
Joseph Doherty 45c530da6e chore(clients): regenerate Go/Java bindings and Rust vendored proto for the statuses contract comments
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 7s
ci / windows-x86 (push) Failing after 1m8s
ci / portable (push) Successful in 7m18s
2026-08-09 12:37:35 -04:00
Joseph Doherty c867aca36b test(worker): deterministic pump-wait ordering, env hermeticity, ResolveWriteCompletionTimeout coverage 2026-08-09 12:37:35 -04:00
Joseph Doherty 436ef69f07 fix(worker): thread the completion cache through CreateForTesting
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 1m57s
ci / windows-x86 (push) Failing after 1m57s
ci / portable (push) Failing after 8m28s
2026-08-09 12:33:20 -04:00
Joseph Doherty 2b468bd8fc docs: write-completion correlation configuration and semantics
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 43s
ci / java (push) Failing after 1m56s
ci / portable (push) Failing after 4m24s
2026-08-09 12:28:30 -04:00
Joseph Doherty 431a096cab feat(gateway): configurable worker write-completion wait (MxGateway:Worker:WriteCompletionWaitMilliseconds) 2026-08-09 12:27:30 -04:00
Joseph Doherty b0e2b8ba74 test(worker): write-completion correlation executor coverage 2026-08-09 12:26:23 -04:00
Joseph Doherty 66fe063410 feat(worker): bounded pump-wait correlates OnWriteComplete onto secured-write replies 2026-08-09 12:24:23 -04:00
Joseph Doherty 8de23086d0 feat(worker): share the completion cache between sink and session 2026-08-09 12:23:20 -04:00
Joseph Doherty a76ecdd59c feat(worker): event sink records OnWriteComplete rows into the completion cache 2026-08-09 12:22:54 -04:00
Joseph Doherty fc23a65cca feat(worker): versioned OnWriteComplete completion cache 2026-08-09 12:21:51 -04:00
Joseph Doherty aec95b78c9 docs(proto): document the correlated write-completion statuses contract 2026-08-09 12:20:36 -04:00
Joseph Doherty f9229ee44d docs(plan): write-completion correlation implementation plan 2026-08-09 12:20:00 -04:00
Joseph Doherty 5dbe93d13e docs(design): WriteSecured completion correlation onto the unary reply (OtOpcUa 06/S-1) 2026-08-09 12:15:07 -04:00
Joseph Doherty 129e47e541 docs(tracking): close NEXT-07, file NEXT-08/09/10, record the runner token reset
NEXT-07 is struck: windev was redeployed from origin/main (a346d51) and the service is
healthy, and the root cause the row predicted is confirmed -- the 2026-06-25 build's
Auth.ApiKeys 0.1.2.0 supports auth-DB schema 2 while the database sits at schema 3, which
is the current shared-lib version, so deploying forward was the fix rather than touching
the DB. The original text stays for the triage record.

Three findings surfaced by that work, each deliberately left for the next cycle rather
than patched in passing:

- NEXT-08: the shared GLAuth offers no TLS, so SEC-06 makes GatewayConfiguration.md's
  "deployed hosts must set Ldaps or StartTls" unsatisfiable for anything genuinely
  labelled Production. windev's relabel to Staging is honest for a dev rig but defers
  the posture question rather than answering it.
- NEXT-09: Directory.Build.props:29 quotes a path ending in a backslash, so the SHA-stamp
  git invocation is malformed on Windows and ContinueOnError stamps git's stderr into
  InformationalVersion -- a Windows binary cannot be correlated to a commit, which is what
  TST-11 exists to guarantee.
- NEXT-10: glauth.md's pre-provisioned-user table contradicts both the directory and its
  own dashboard section, and was the root cause of the NEXT-06 fixture drift. Reconciling
  it sweeps the OPC-UA group taxonomy, so it is scoped out here on purpose.

The TST-30 runner work is hygiene, not closure: runner-1 now mounts its registration token
from a 0600 file like runner-2, but both still share one instance-scope token that was
world-readable for months and is provably still live. Gitea 1.26.4 cannot rotate it from
the CLI or API, so the UI reset is recorded as a pending operator action with its
follow-through (refresh the token file, shred the token-bearing compose backups).
2026-08-07 10:31:29 -04:00
Joseph Doherty 1d6858939d docs(sec-36): record the completed windev dashboard verification
SEC-36's primary check -- dashboard /login through the real DashboardAuthenticator
search bind -- was deferred because windev's gateway was crash-looping on the stale
deployment filed as NEXT-07. That host was redeployed 2026-08-07, so the check ran:
login as multi-role returns 302 with the dashboard cookie and the authenticated page
renders the admin nav, while an anonymous control still redirects to /login. The
rotated service-account credential is now proven end-to-end on the deployed host, not
only by the equivalent ldapsearch primitive, and the runbook's Correction 3 is past
tense throughout rather than describing a fault that no longer exists.

Also record why windev runs the Staging environment name. The redeploy tripped SEC-06's
Production hard-stop on Ldap:Transport=None, and windev cannot satisfy it: it binds the
shared GLAuth, which offers no TLS, and runs Dashboard:DisableLogin=true. The Production
label contradicted its own configuration, so the host was relabelled rather than the
guard weakened -- exactly the permissive-staging-rig case the SEC-35 section already
carves out.
2026-08-07 10:31:15 -04:00
Joseph Doherty de67b45d04 test(ldap): align DashboardLdapLiveTests fixtures with the shared directory (NEXT-06)
The suite's fixtures had drifted from the shared GLAuth config, so a green run
proved nothing about the service-account bind: the only success-path test used
admin/admin123, but the directory's admin carries the standard dev password, and
the "not an admin" test used a readonly user that does not exist there at all --
it passed via the user-not-found branch rather than the group-missing branch it
names.

Realign to real users from scadaproj/infra/glauth/config.toml: admin/password
(othergroups include GwAdmin, gid 5610) for the success path, and
gw-viewer/password (GwReader only, gid 5611) for the bind-succeeds-but-no-role
path. Both are published dev credentials documented in glauth.md, not secrets.

The gw-viewer test drops its old no-leak assertion on the credential literal:
the real password is the word "password", which legitimately occurs in the
generic denial text, so the check would fail for the wrong reason. The no-leak
property is still covered with a distinctive literal by the wrong-password test.
In its place the test now asserts the property this fixture is uniquely able to
prove -- an authorization failure must be reported with the same message as an
authentication failure, so it cannot be used to enumerate valid accounts.

appsettings ships Server=localhost, so document the MxGateway__Ldap__Server
override the suite needs to reach the shared GLAuth alongside the existing
MXGATEWAY_RUN_LIVE_LDAP_TESTS and ServiceAccountPassword variables.

Verified live: Failed: 0, Passed: 5 against 10.100.0.35:3893.
2026-08-07 10:03:10 -04:00
Joseph Doherty 3d991d2160 docs(tst-30): remove mislabelled macOS instance runner (id 4)
The local act_runner on this Mac registered as instance runner id 4 with
ubuntu-latest/22.04/20.04 labels, so it competed with the two docker
runners on 10.100.0.35 for Linux jobs it had no Docker daemon to run --
13 of the last 20-run window in historiangw landed on it and all but one
failed. Registration deleted; local config kept disabled for re-use with
mac-specific labels.
2026-08-07 10:02:29 -04:00
Joseph Doherty 05667169eb docs: sync runner-topology and SEC-36 rotation prose with 2026-08-07 executed state 2026-08-07 09:28:49 -04:00
Joseph Doherty 9760497d66 docs(sec-36): rotation executed 2026-08-07; runbook host-path/vd03/verification corrections; new findings (LDAP test fixtures, windev stale deploy) 2026-08-07 09:21:54 -04:00
Joseph Doherty 5b153dac74 docs(clients): record 2026-08-07 publish of 0.2.0 client family (Java 0.2.1); cargo token needs Bearer prefix 2026-08-07 09:15:47 -04:00
Joseph Doherty 41e86481e2 docs(tst-30): second runner gitea-runner-2 live; close operator action 2026-08-07 09:12:00 -04:00
36 changed files with 2618 additions and 66 deletions
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ Repo rules that bind every entry: docs change in the same commit as the source (
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated | | SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation | | SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
| SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name | | SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name |
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done (repo-side; live rotation operator-pending per runbook) | Committed dev LDAP service-account password: remove from repo and rotate | | SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done | Committed dev LDAP service-account password: remove from repo and rotate |
--- ---
@@ -220,3 +220,7 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
(asserts the blank-password validation still fires with the updated message). Manual: with user-secrets set on the dev box, `dotnet run --project src/ZB.MOM.WW.MxGateway.Server/...` and a dashboard `/login` as `multi-role` succeeds against the rotated GLAuth; the deployed-host login re-check from step 1 counts as the production verification. Live-LDAP integration tests (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) only where the GLAuth instance is reachable; otherwise document skipped per the testing matrix. (asserts the blank-password validation still fires with the updated message). Manual: with user-secrets set on the dev box, `dotnet run --project src/ZB.MOM.WW.MxGateway.Server/...` and a dashboard `/login` as `multi-role` succeeds against the rotated GLAuth; the deployed-host login re-check from step 1 counts as the production verification. Live-LDAP integration tests (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) only where the GLAuth instance is reachable; otherwise document skipped per the testing matrix.
**Outcome (2026-08-07 — Done, repo-side; live rotation operator-pending).** Landed on `fix/sec-36-ldap-secret`. **The design's baseline had already shifted:** at HEAD `appsettings.json` no longer commits the literal — it ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"`, a fail-closed encrypted-store reference (documented `GatewayConfiguration.md:252`, tested by `PreHostSecretExpansionTests`) introduced by the Secrets-store adoption after this remediation was written. **Deviation from Implementation step 2:** the `${secret:}` reference was **kept, not deleted** — deleting it regresses the shipped/documented/tested store channel and the committed-plaintext finding is already resolved for `appsettings.json`. The load-bearing residual — the literal value still present in `glauth.md`'s samples (`:33,65,103,136,245`), `docs/GatewayTesting.md`, and the historical `archreview/*` SEC-06 evidence — was scrubbed to `<service-account-password>` placeholders, each with a pointer to the source of truth `scadaproj/infra/glauth/` and a rotation-required note. Steps 36 implemented as designed: `<UserSecretsId>mxaccessgw-server</UserSecretsId>` added (step 3); the `ValidateLdap` blank-password message now names both channels — dev `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` and deployed `MxGateway__Ldap__ServiceAccountPassword` — plus a note on the `${secret:}` store default (step 4), asserted by the extended `Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank`; docs updated same commit (step 5); `git grep -i` for the old value is empty across tracked files (step 6). The cross-repo **step 1 (rotate GLAuth on `10.100.0.35`, pre-stage the NSSM env var on `10.100.0.48` and on `wonder-app-vd03` only if `Ldap.Enabled`, verify dashboard login)** is the operator's to execute, captured in the new runbook `docs/runbooks/SEC-36-ldap-credential-rotation.md`. Verification (macOS): `dotnet build …Server` 0 warnings/0 errors; `dotnet test --filter ~GatewayOptionsValidator` green. **Outcome (2026-08-07 — Done, repo-side; live rotation operator-pending).** Landed on `fix/sec-36-ldap-secret`. **The design's baseline had already shifted:** at HEAD `appsettings.json` no longer commits the literal — it ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"`, a fail-closed encrypted-store reference (documented `GatewayConfiguration.md:252`, tested by `PreHostSecretExpansionTests`) introduced by the Secrets-store adoption after this remediation was written. **Deviation from Implementation step 2:** the `${secret:}` reference was **kept, not deleted** — deleting it regresses the shipped/documented/tested store channel and the committed-plaintext finding is already resolved for `appsettings.json`. The load-bearing residual — the literal value still present in `glauth.md`'s samples (`:33,65,103,136,245`), `docs/GatewayTesting.md`, and the historical `archreview/*` SEC-06 evidence — was scrubbed to `<service-account-password>` placeholders, each with a pointer to the source of truth `scadaproj/infra/glauth/` and a rotation-required note. Steps 36 implemented as designed: `<UserSecretsId>mxaccessgw-server</UserSecretsId>` added (step 3); the `ValidateLdap` blank-password message now names both channels — dev `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` and deployed `MxGateway__Ldap__ServiceAccountPassword` — plus a note on the `${secret:}` store default (step 4), asserted by the extended `Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank`; docs updated same commit (step 5); `git grep -i` for the old value is empty across tracked files (step 6). The cross-repo **step 1 (rotate GLAuth on `10.100.0.35`, pre-stage the NSSM env var on `10.100.0.48` and on `wonder-app-vd03` only if `Ldap.Enabled`, verify dashboard login)** is the operator's to execute, captured in the new runbook `docs/runbooks/SEC-36-ldap-credential-rotation.md`. Verification (macOS): `dotnet build …Server` 0 warnings/0 errors; `dotnet test --filter ~GatewayOptionsValidator` green.
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The cross-repo step 1 left open above was executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`. A new service-account password was generated, the `serviceaccount` `passsha256` in `scadaproj/infra/glauth/config.toml` replaced, and the shared GLAuth recreated on `10.100.0.35` from its actual compose directory — the **load-bearing** half of the finding is now discharged: the value disclosed by this repo's git history (live in the directory since 2026-06-04; not reproduced here) no longer binds `dc=zb,dc=local`. The new value exists only in the three channels the design named — the GLAuth `passsha256` (committed in `scadaproj`, commit `aada53b`), the NSSM service environment on `10.100.0.48`, and each dev box's user-secrets — and in no file of this repo. The retired plaintext was additionally scrubbed from the `scadaproj` glauth comments (`config.toml`, `docker-compose.yml`, `README.md`) and from the docker host's live `docker-compose.yml`; the host's `*.bak-sec36` rollback copies deliberately retain it. **Three runbook facts were wrong and are corrected in a dated block at its top.** (1) Its step 3 said `cd ~/Desktop/scadaproj/infra/glauth` on the docker host; no such path exists there — the stack runs from `/home/dohertj2/zb-glauth` (container `zb-shared-glauth`), fed by the `scp` deploy documented in `scadaproj/infra/glauth/README.md`. (2) `wonder-app-vd03` is **out of scope on documentary evidence**, not merely unchecked: its gateway binds the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`, a different directory that never held this credential (the host is also unreachable from the dev network); no env var was staged there. (3) Its "3-fail / 10-minute per-IP lockout" caution is **inert for this instance**`config.toml:14` sets `LimitFailedBinds = false`. **One Done criterion is met with a caveat:** the new value **is** staged on windev (`10.100.0.48`, 10th `AppEnvironmentExtra` entry on the `MxAccessGw` NSSM service), but the runbook's primary check — dashboard `/login` as `multi-role`**could not run**, because windev's gateway is crash-looping on an unrelated pre-existing fault: the deployed Server binary (2026-06-25) predates the 2026-07-15 auth-DB migration, so it opens a schema-version-3 database it supports only at version 2 and aborts at startup (~10k Hosting-failed events/day since at least 08-06). That is a stale-deployment problem, filed as a next-cycle candidate finding, not a rotation defect. **Verified instead by the equivalent primitive:** a direct `ldapsearch` bind as `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and returned the `multi-role` entry — the same search bind the dashboard performs. Also surfaced and filed for next cycle: `DashboardLdapLiveTests` fixture drift leaves the suite with **no positive-proof coverage** of the service-account bind, so it could not have substituted for the dashboard check either. Tracking: both registers' SEC-36 rows, the pending-operator-actions list in `90-candidate-findings-next-cycle.md`, and the `00-tracking.md` progress log.
**Addendum (2026-08-07, later the same day — the caveat is closed).** windev was repaired under NEXT-07 (fresh publish of `origin/main` `a346d51`), and the deferred dashboard check then ran on that host: with `Dashboard:DisableLogin=false` supplied as a process-env-only override on a foreground run, `GET /login` returned 200 with an antiforgery token, `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard` cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to `/login?ReturnUrl=%2F`. The rotated credential is therefore proven through the real `DashboardAuthenticator` search-bind path on the deployed host, not only by the `ldapsearch` primitive. As deployed windev keeps `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression proof is the realigned `DashboardLdapLiveTests` (NEXT-06, commit `de67b45`), 5/5 green against the shared GLAuth. `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3 carries the same record.
@@ -15,7 +15,7 @@ Prior-cycle open findings (TST-05..24 where still open) are tracked in the prior
| TST-27 | Medium | P1 (doc batch) | S | — | Done | `ShowTagValues` config row still says "Reserved" after SEC-25 made the flag live | | TST-27 | Medium | P1 (doc batch) | S | — | Done | `ShowTagValues` config row still says "Reserved" after SEC-25 made the flag live |
| TST-28 | Low | P2 | S | relates IPC-02 | Done | Gateway-side `max_frame_bytes` handshake field untested in the CI-run suite | | TST-28 | Low | P2 | S | relates IPC-02 | Done | Gateway-side `max_frame_bytes` handshake field untested in the CI-run suite |
| TST-29 | Low | P2 | S | — | Done | Retire `oldtasks.md` after folding the Phase-5 governance record into DesignDecisions.md; delete root docs-review artifacts | | TST-29 | Low | P2 | S | — | Done | Retire `oldtasks.md` after folding the Phase-5 governance record into DesignDecisions.md; delete root docs-review artifacts |
| TST-30 | Low | P2 | M | — | Done (doc half; runner registration operator-pending per runbook) | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) | | TST-30 | Low | P2 | M | — | Done | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
--- ---
@@ -169,6 +169,8 @@ Independent of the runner count, document the **no-cancel** reality (Gitea 1.26
**Outcome (2026-08-07 — Done, doc half; runner registration operator-pending).** Landed on `fix/tst-30-runner-docs`. Implementation step 2 shipped: `docs/GatewayTesting.md`'s Continuous Integration section gained a "Runner capacity is shared and finite" subsection stating the `maxParallel=1` co-located runner is shared with `dohertj2/lmxopcua` at the instance level (not repo-scoped), the ~2030 minute queue latency observed under cross-repo contention, and the Gitea 1.26 no-cancel/no-delete API reality; the existing "windev tier down" degraded-mode paragraph now also covers "runner contended" as a reason to use the bypass, generalized per this finding's design note. New operator runbook `docs/runbooks/TST-30-second-ci-runner.md` carries **step 1** (register a second `act_runner` on `10.100.0.35`, option (a) recommended, same `container.network: traefik` config; option (b) dedicated labelled runner as an escalation; option (c) windev-hosted runner rejected) with the verification checklist (concurrent back-to-back pushes, `GET /repos/dohertj2/mxaccessgw/actions/runners` ≥ 2) and a note that the no-cancel reality persists regardless of runner count. **Step 3 (optional workflow-level `concurrency` group)** is documented in the runbook as unverified — explicitly framed as "verify this Gitea deployment honors it before relying on it" — and left unimplemented in `ci.yml`, since it is a `ci.yml` change out of scope for this doc-only pass. **The actual runner registration (step 1) is infrastructure work outside this repo's tree and remains the operator's to execute**, tracked in the runbook. Verification performed: `grep -n 'maxParallel\|shared\|cancel' docs/GatewayTesting.md` shows the new prose; runbook file exists at the path above; no build required (doc-only change). **Outcome (2026-08-07 — Done, doc half; runner registration operator-pending).** Landed on `fix/tst-30-runner-docs`. Implementation step 2 shipped: `docs/GatewayTesting.md`'s Continuous Integration section gained a "Runner capacity is shared and finite" subsection stating the `maxParallel=1` co-located runner is shared with `dohertj2/lmxopcua` at the instance level (not repo-scoped), the ~2030 minute queue latency observed under cross-repo contention, and the Gitea 1.26 no-cancel/no-delete API reality; the existing "windev tier down" degraded-mode paragraph now also covers "runner contended" as a reason to use the bypass, generalized per this finding's design note. New operator runbook `docs/runbooks/TST-30-second-ci-runner.md` carries **step 1** (register a second `act_runner` on `10.100.0.35`, option (a) recommended, same `container.network: traefik` config; option (b) dedicated labelled runner as an escalation; option (c) windev-hosted runner rejected) with the verification checklist (concurrent back-to-back pushes, `GET /repos/dohertj2/mxaccessgw/actions/runners` ≥ 2) and a note that the no-cancel reality persists regardless of runner count. **Step 3 (optional workflow-level `concurrency` group)** is documented in the runbook as unverified — explicitly framed as "verify this Gitea deployment honors it before relying on it" — and left unimplemented in `ci.yml`, since it is a `ci.yml` change out of scope for this doc-only pass. **The actual runner registration (step 1) is infrastructure work outside this repo's tree and remains the operator's to execute**, tracked in the runbook. Verification performed: `grep -n 'maxParallel\|shared\|cancel' docs/GatewayTesting.md` shows the new prose; runbook file exists at the path above; no build required (doc-only change).
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The runner registration left open above was executed per `docs/runbooks/TST-30-second-ci-runner.md` option (a). A second instance-level `act_runner` container, `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/`ubuntu-22.04`), now runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same `container.network: traefik` setting as the original; its registration token is mounted from a `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was **not** modified — capacity went 4 → 6 by addition, so the change is reversible by removing one container. Concurrency verified live by pushing HEAD (`a346d51`) to two scratch branches, `scratch/tst30-a` (run 661) and `scratch/tst30-b` (run 662), while an unrelated run (660) was already in flight: at 13:07:53Z jobs from **three** runs were `in_progress` simultaneously — run 660 `portable` and run 662 `portable`/`java` on runner 1, run 661 `portable`/`java` on `gitea-runner-2` — which the pre-change single-runner topology could not have produced. `gitea:3000` resolution holds on the new instance: run 661's `portable` job (task 1145, scheduled on `gitea-runner-2`) logged `git remote add origin http://gitea:3000/dohertj2/mxaccessgw` followed by a successful `fetch … From http://gitea:3000/dohertj2/mxaccessgw`, and its job container's workspace was confirmed checked out at `a346d514dd24e775640e5667aa7cd8e561fec68a`. **Runbook correction:** its verification checklist said `GET /repos/dohertj2/mxaccessgw/actions/runners` should show ≥2 — that endpoint still returns `total_count: 0` because both runners are registered at the **instance** level, exactly as this finding documented; the correct check is `GET /api/v1/admin/actions/runners`, which lists ids 1, 4 (an unrelated local macOS runner), and 5. Recorded as a dated "Executed" note at the top of the runbook. The no-cancel reality is unchanged and the `run-windev-ci.sh` bypass remains valid, so `docs/GatewayTesting.md`'s prose needed no edit.
--- ---
## Cross-domain dependencies ## Cross-domain dependencies
@@ -1,6 +1,6 @@
# Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation) # Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation)
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here (not fixed) so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete. These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete. Rows struck through have since been fixed ahead of that cycle; the original finding text is kept so the triage record stays readable.
| ID (proposed) | Area | Severity (est.) | Summary | | ID (proposed) | Area | Severity (est.) | Summary |
|---|---|---|---| |---|---|---|---|
@@ -9,11 +9,17 @@ These were discovered while remediating the 2026-07-12 backlog but were **out of
| NEXT-03 | Gateway alarms | Low | `GatewayAlarmMonitor.ApplyReconcile` feed-repair broadcasts (the new acked-delta from GWC-26 **and** the pre-existing Raise/Clear repair) are **at-least-once, not exactly-once**: a periodic reconcile can synthesize a transition whose matching live transition is still buffered in the alarm lease, so both broadcast as indistinguishable duplicates on the alarm feed (StreamAlarms + dashboard hub). Pre-existing (the Raise/Clear repair always had it); GWC-26 documented the at-least-once contract rather than closing the race. Closing it needs reconcile/live serialization or a monotonic dedup marker. | | NEXT-03 | Gateway alarms | Low | `GatewayAlarmMonitor.ApplyReconcile` feed-repair broadcasts (the new acked-delta from GWC-26 **and** the pre-existing Raise/Clear repair) are **at-least-once, not exactly-once**: a periodic reconcile can synthesize a transition whose matching live transition is still buffered in the alarm lease, so both broadcast as indistinguishable duplicates on the alarm feed (StreamAlarms + dashboard hub). Pre-existing (the Raise/Clear repair always had it); GWC-26 documented the at-least-once contract rather than closing the race. Closing it needs reconcile/live serialization or a monotonic dedup marker. |
| NEXT-04 | Worker frame writer | Low | WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. | | NEXT-04 | Worker frame writer | Low | WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. |
| NEXT-05 | Worker frame writer | Info | A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. | | NEXT-05 | Worker frame writer | Info | A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
| ~~NEXT-06~~ | Testing / live LDAP | Medium | **Resolved 2026-08-07** — fixtures realigned to the shared directory (`admin`/`password` for the GwAdmin success path, `gw-viewer`/`password` for the bind-succeeds-but-no-role path); verified `Failed: 0, Passed: 5` live against the shared GLAuth at `10.100.0.35:3893`, so the success-path assertion (GwAdmin group claim + Admin role claim) now fails if the service-account credential is wrong. Original finding: `DashboardLdapLiveTests` fixtures have drifted from the shared GLAuth directory, leaving the suite with **no positive-proof coverage of the service-account bind**. Its only success-path test, `AuthenticateAsync_AdminInGwAdminGroup_Succeeds`, binds `admin`/`admin123`, but the directory's `admin` user carries the standard dev password (`scadaproj/infra/glauth/config.toml`), so that assertion cannot pass. `AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails` binds fixture user `readonly`, which **does not exist** in the GLAuth config at all — it passes for the wrong reason (user-not-found rather than the group-missing branch it names; the `readonly` name is in fact barred by the README's user/group case-collision rule). The three remaining tests are negative assertions that pass whether or not the service account can bind. Net effect: a green `DashboardLdapLiveTests` run proves nothing about the bind credential — surfaced during SEC-36, where the suite was considered as a substitute for the deferred dashboard-login check and rejected. Fix: realign the fixtures to real directory users (e.g. `multi-role`/`gw-viewer`) or add the missing users to the GLAuth config, and add one test that fails when the service-account credential is wrong. |
| ~~NEXT-07~~ | Deployment / windev | High | **Resolved 2026-08-07** — a fresh portable framework-dependent publish of `origin/main` (`a346d51`) was built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807`, and the `MxAccessGw` NSSM service repointed at it; the service now holds a stable PID with both `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. Root cause confirmed as the version skew this row predicted: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0, which supports auth-DB schema 2, against a `gateway-auth.db` stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the current shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so the redeploy is the forward fix and the DB was left alone. Rollback artifacts kept: `C:\ProgramData\MxGateway\gateway-auth.db.bak-next07` (with `-wal`/`-shm`) and the previous `C:\publish\mxaccessgw\Server` directory. Two side effects worth recording: the old deploy's `appsettings.json` held the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value, so no plaintext LDAP secret remains on that host; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling the host — windev runs `Dashboard:DisableLogin=true`, which this repo's own docs mark dev/test-only, so its `Production` label contradicted its configuration and `DOTNET_ENVIRONMENT` was changed to `Staging` (that one NSSM environment entry only; the other nine preserved byte-identical). SEC-06 is untouched for genuinely production hosts — see NEXT-08 for the posture problem that relabelling defers. Original finding: The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
| NEXT-08 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
| NEXT-09 | Build / versioning | Low | **Windows builds stamp git's error text into `InformationalVersion`.** `src/Directory.Build.props:29` runs `git -C "$(MSBuildThisFileDirectory)" …`; MSBuild's directory property ends in a backslash, which escapes the closing quote, so the command is malformed on Windows. The target carries `ContinueOnError`, so the failure is silent and git's stderr is captured as the revision — an observed stamp reads `0.1.2+fatal: cannot change to …`. Any Windows build without a preset `SourceRevisionId` therefore ships a binary that cannot be correlated back to a commit, defeating the point of TST-11. Not reproducible on macOS/Linux, where the separator is `/`. Fix sketch: append `.` to the path or trim the trailing separator before quoting. Surfaced while identifying the deployed binary during NEXT-07. |
| NEXT-10 | Docs / glauth | Medium | **`glauth.md`'s "Pre-provisioned users" table contradicts both the directory and the rest of its own file.** It documents `readonly`/`readonly123` and `admin`/`admin123`, neither of which matches `scadaproj/infra/glauth/config.toml` (`readonly` does not exist there; `admin` carries the standard dev password), and lists the `ReadOnly` gid as `5501` against an actual `5601`. Its dashboard section, by contrast, is correct — so the file is internally inconsistent and a reader cannot tell which half to trust. This table was the **root cause of the NEXT-06 fixture drift**, and it has propagated further: `docs/GatewayTesting.md`'s `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the matching literal in `WorkerLiveMxAccessSmokeTests` both take `admin123` from it. Deliberately **not** fixed in the 2026-08-07 pass: the table is entangled with the OPC-UA group taxonomy (gids, role mapping, and the sister-repo consumers of the same directory), so reconciling it means sweeping that taxonomy as one unit rather than patching two rows. |
## Operator actions still pending (from this cycle's runbooks) ## Operator actions still pending (from this cycle's runbooks)
These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged: These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged:
- **SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.** - ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat, since closed:** windev's dashboard `/login` verification was deferred while that host's gateway was crash-looping on the unrelated stale-deployment fault filed as NEXT-07, so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. After the 2026-08-07 redeploy the real check ran on windev (login as `multi-role` → 302 + dashboard cookie, anonymous control → `/login`), so the rotated credential is now proven through the `DashboardAuthenticator` path itself; see `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3. SEC-36 is fully `Done`.
- **TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck. - ~~**TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.~~ **Executed 2026-08-07**: `gitea-runner-2` (id 5, capacity 2) is online on `10.100.0.35` via the `/opt/gitea` compose stack, same `container.network: traefik`, token from a `0600` file mount; the existing runner (id 1, capacity 4) was untouched. Concurrency verified — jobs from three runs ran simultaneously across both runners, and a `gitea-runner-2` job cloned successfully from `http://gitea:3000`. TST-30 is now fully `Done`.
- **TST-30 follow-up — reset the Gitea instance runner registration token.** Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier. - **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.
@@ -2553,6 +2553,9 @@ func (x *ActivateCommand) GetItemHandle() int32 {
return 0 return 0
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait — see
// MxCommandReply.statuses.
type WriteCommand struct { type WriteCommand struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
@@ -2621,6 +2624,7 @@ func (x *WriteCommand) GetUserId() int32 {
return 0 return 0
} }
// Same statuses correlation as WriteCommand.
type Write2Command struct { type Write2Command struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
@@ -2697,6 +2701,9 @@ func (x *Write2Command) GetUserId() int32 {
return 0 return 0
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait — see
// MxCommandReply.statuses.
type WriteSecuredCommand struct { type WriteSecuredCommand struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
@@ -2775,6 +2782,9 @@ func (x *WriteSecuredCommand) GetValue() *MxValue {
return nil return nil
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait — see
// MxCommandReply.statuses.
type WriteSecured2Command struct { type WriteSecured2Command struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
@@ -4577,6 +4587,19 @@ type MxCommandReply struct {
// transport failures. // transport failures.
Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"` Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"` ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"`
// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
// WRITE_SECURED2 replies the worker holds the reply for a bounded window
// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
// the matching MXAccess OnWriteComplete callback and copies its status rows
// here, so statuses[0] carries the real MXAccess commit outcome (success OR
// failure) while protocol_status/hresult still describe command acceptance
// only. Empty statuses on a write reply means the completion did not arrive
// within the window — the write is unconfirmed, not failed. Correlation is
// best-effort per (server_handle, item_handle): MXAccess's callback carries
// no transaction id, so concurrent writes to the same item within the
// window can swap rows. The OnWriteComplete event still flows on the event
// stream unchanged. Bulk write kinds and all non-write kinds leave this
// field as before.
Statuses []*MxStatusProxy `protobuf:"bytes,7,rep,name=statuses,proto3" json:"statuses,omitempty"` Statuses []*MxStatusProxy `protobuf:"bytes,7,rep,name=statuses,proto3" json:"statuses,omitempty"`
DiagnosticMessage string `protobuf:"bytes,8,opt,name=diagnostic_message,json=diagnosticMessage,proto3" json:"diagnostic_message,omitempty"` DiagnosticMessage string `protobuf:"bytes,8,opt,name=diagnostic_message,json=diagnosticMessage,proto3" json:"diagnostic_message,omitempty"`
// Types that are valid to be assigned to Payload: // Types that are valid to be assigned to Payload:
@@ -24235,6 +24235,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
int getUserId(); int getUserId();
} }
/** /**
* <pre>
* The unary reply's statuses field carries the correlated OnWriteComplete
* outcome when it arrives within the worker's bounded wait see
* MxCommandReply.statuses.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.WriteCommand} * Protobuf type {@code mxaccess_gateway.v1.WriteCommand}
*/ */
public static final class WriteCommand extends public static final class WriteCommand extends
@@ -24527,6 +24533,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return builder; return builder;
} }
/** /**
* <pre>
* The unary reply's statuses field carries the correlated OnWriteComplete
* outcome when it arrives within the worker's bounded wait see
* MxCommandReply.statuses.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.WriteCommand} * Protobuf type {@code mxaccess_gateway.v1.WriteCommand}
*/ */
public static final class Builder extends public static final class Builder extends
@@ -25036,6 +25048,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
int getUserId(); int getUserId();
} }
/** /**
* <pre>
* Same statuses correlation as WriteCommand.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.Write2Command} * Protobuf type {@code mxaccess_gateway.v1.Write2Command}
*/ */
public static final class Write2Command extends public static final class Write2Command extends
@@ -25370,6 +25386,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return builder; return builder;
} }
/** /**
* <pre>
* Same statuses correlation as WriteCommand.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.Write2Command} * Protobuf type {@code mxaccess_gateway.v1.Write2Command}
*/ */
public static final class Builder extends public static final class Builder extends
@@ -26028,6 +26048,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getValueOrBuilder(); mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getValueOrBuilder();
} }
/** /**
* <pre>
* The unary reply's statuses field carries the correlated OnWriteComplete
* outcome when it arrives within the worker's bounded wait see
* MxCommandReply.statuses.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand} * Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand}
*/ */
public static final class WriteSecuredCommand extends public static final class WriteSecuredCommand extends
@@ -26357,6 +26383,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return builder; return builder;
} }
/** /**
* <pre>
* The unary reply's statuses field carries the correlated OnWriteComplete
* outcome when it arrives within the worker's bounded wait see
* MxCommandReply.statuses.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand} * Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand}
*/ */
public static final class Builder extends public static final class Builder extends
@@ -26976,6 +27008,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getTimestampValueOrBuilder(); mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getTimestampValueOrBuilder();
} }
/** /**
* <pre>
* The unary reply's statuses field carries the correlated OnWriteComplete
* outcome when it arrives within the worker's bounded wait see
* MxCommandReply.statuses.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command} * Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command}
*/ */
public static final class WriteSecured2Command extends public static final class WriteSecured2Command extends
@@ -27347,6 +27385,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return builder; return builder;
} }
/** /**
* <pre>
* The unary reply's statuses field carries the correlated OnWriteComplete
* outcome when it arrives within the worker's bounded wait see
* MxCommandReply.statuses.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command} * Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command}
*/ */
public static final class Builder extends public static final class Builder extends
@@ -50488,24 +50532,104 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getReturnValueOrBuilder(); mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getReturnValueOrBuilder();
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy>
getStatusesList(); getStatusesList();
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index); mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index);
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
int getStatusesCount(); int getStatusesCount();
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder>
getStatusesOrBuilderList(); getStatusesOrBuilderList();
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder( mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder(
@@ -51233,6 +51357,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
@SuppressWarnings("serial") @SuppressWarnings("serial")
private java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> statuses_; private java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> statuses_;
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
@java.lang.Override @java.lang.Override
@@ -51240,6 +51380,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return statuses_; return statuses_;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
@java.lang.Override @java.lang.Override
@@ -51248,6 +51404,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return statuses_; return statuses_;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
@java.lang.Override @java.lang.Override
@@ -51255,6 +51427,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return statuses_.size(); return statuses_.size();
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
@java.lang.Override @java.lang.Override
@@ -51262,6 +51450,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return statuses_.get(index); return statuses_.get(index);
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
@java.lang.Override @java.lang.Override
@@ -53981,6 +54185,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> statusesBuilder_; mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> statusesBuilder_;
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> getStatusesList() { public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> getStatusesList() {
@@ -53991,6 +54211,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
} }
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public int getStatusesCount() { public int getStatusesCount() {
@@ -54001,6 +54237,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
} }
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index) { public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index) {
@@ -54011,6 +54263,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
} }
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder setStatuses( public Builder setStatuses(
@@ -54028,6 +54296,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder setStatuses( public Builder setStatuses(
@@ -54042,6 +54326,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder addStatuses(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy value) { public Builder addStatuses(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy value) {
@@ -54058,6 +54358,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder addStatuses( public Builder addStatuses(
@@ -54075,6 +54391,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder addStatuses( public Builder addStatuses(
@@ -54089,6 +54421,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder addStatuses( public Builder addStatuses(
@@ -54103,6 +54451,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder addAllStatuses( public Builder addAllStatuses(
@@ -54118,6 +54482,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder clearStatuses() { public Builder clearStatuses() {
@@ -54131,6 +54511,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public Builder removeStatuses(int index) { public Builder removeStatuses(int index) {
@@ -54144,6 +54540,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder getStatusesBuilder( public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder getStatusesBuilder(
@@ -54151,6 +54563,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
return internalGetStatusesFieldBuilder().getBuilder(index); return internalGetStatusesFieldBuilder().getBuilder(index);
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder( public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder(
@@ -54161,6 +54589,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
} }
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> public java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder>
@@ -54172,6 +54616,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
} }
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder() { public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder() {
@@ -54179,6 +54639,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance()); mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance());
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder( public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder(
@@ -54187,6 +54663,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
index, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance()); index, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance());
} }
/** /**
* <pre>
* Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
* WRITE_SECURED2 replies the worker holds the reply for a bounded window
* (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
* the matching MXAccess OnWriteComplete callback and copies its status rows
* here, so statuses[0] carries the real MXAccess commit outcome (success OR
* failure) while protocol_status/hresult still describe command acceptance
* only. Empty statuses on a write reply means the completion did not arrive
* within the window the write is unconfirmed, not failed. Correlation is
* best-effort per (server_handle, item_handle): MXAccess's callback carries
* no transaction id, so concurrent writes to the same item within the
* window can swap rows. The OnWriteComplete event still flows on the event
* stream unchanged. Bulk write kinds and all non-write kinds leave this
* field as before.
* </pre>
*
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code> * <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
*/ */
public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder> public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder>
@@ -241,6 +241,9 @@ message ActivateCommand {
int32 item_handle = 2; int32 item_handle = 2;
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait see
// MxCommandReply.statuses.
message WriteCommand { message WriteCommand {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -248,6 +251,7 @@ message WriteCommand {
int32 user_id = 4; int32 user_id = 4;
} }
// Same statuses correlation as WriteCommand.
message Write2Command { message Write2Command {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -256,6 +260,9 @@ message Write2Command {
int32 user_id = 5; int32 user_id = 5;
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait see
// MxCommandReply.statuses.
message WriteSecuredCommand { message WriteSecuredCommand {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -266,6 +273,9 @@ message WriteSecuredCommand {
MxValue value = 5; MxValue value = 5;
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait see
// MxCommandReply.statuses.
message WriteSecured2Command { message WriteSecured2Command {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -525,6 +535,19 @@ message MxCommandReply {
// transport failures. // transport failures.
optional int32 hresult = 5; optional int32 hresult = 5;
MxValue return_value = 6; MxValue return_value = 6;
// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
// WRITE_SECURED2 replies the worker holds the reply for a bounded window
// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
// the matching MXAccess OnWriteComplete callback and copies its status rows
// here, so statuses[0] carries the real MXAccess commit outcome (success OR
// failure) while protocol_status/hresult still describe command acceptance
// only. Empty statuses on a write reply means the completion did not arrive
// within the window the write is unconfirmed, not failed. Correlation is
// best-effort per (server_handle, item_handle): MXAccess's callback carries
// no transaction id, so concurrent writes to the same item within the
// window can swap rows. The OnWriteComplete event still flows on the event
// stream unchanged. Bulk write kinds and all non-write kinds leave this
// field as before.
repeated MxStatusProxy statuses = 7; repeated MxStatusProxy statuses = 7;
string diagnostic_message = 8; string diagnostic_message = 8;
+18
View File
@@ -99,6 +99,18 @@ numbers are still unclaimed — the guards above do this automatically at
publish time, but a version bump in the source is still a manual step per publish time, but a version bump in the source is still a manual step per
client. client.
On 2026-08-07 that release shipped. Published coordinates on
`gitea.dohertylan.com`: `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.0** and
`ZB.MOM.WW.MxGateway.Contracts` **0.2.0**, `pypi`
`zb-mom-ww-mxaccess-gateway-client` **0.2.0**, `cargo`
`zb-mom-ww-mxgateway-client` **0.2.0**, `maven`
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.1** (the Java
exception described above). Go publishes no artifact — it ships as the module
tag `clients/go/v0.2.0`, created at commit `a346d51`. Each coordinate was
confirmed present through the Gitea package API after the push, and
`go list -m` resolves the Go tag. These are the numbers a future release
bumps off.
## .NET ## .NET
The .NET client uses .NET 10 and references The .NET client uses .NET 10 and references
@@ -200,6 +212,12 @@ a `cargo package` that cannot build from the vendored tree alone would mean
the vendored copies are stale, and verification is what catches that before the vendored copies are stale, and verification is what catches that before
publish. publish.
Publishing to the `dohertj2-gitea` alternative registry reads the token from
`CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN`, and that variable must hold
`Bearer <token>` — cargo sends the value as the `Authorization` header
verbatim and Gitea's cargo registry rejects a bare token with `401`, unlike
the other feeds, which authenticate with a username/token basic-auth pair.
Regenerate and compile Rust bindings: Regenerate and compile Rust bindings:
```powershell ```powershell
+47
View File
@@ -534,6 +534,53 @@ against the live MXAccess attribute set.
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md) - [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
- [gRPC Contract — provider_status and degraded fields](./Grpc.md) - [gRPC Contract — provider_status and degraded fields](./Grpc.md)
## Write Completion Correlation
MXAccess writes are fire-and-forget: the toolkit call returns before the
Galaxy commit, and the per-item outcome only exists in the later
`OnWriteComplete` COM callback. The original unary write reply therefore
proved worker-side command acceptance only, forcing consumers (OtOpcUa's
GalaxyDriver) to report every write as provisionally good.
For the unary write kinds (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`)
the worker now holds the unary reply for a
bounded window (`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default
1.5 s, `0` disables; conveyed to the worker via
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS`) and copies the matching
callback's status rows onto `MxCommandReply.statuses`. Key choices, argued in
[the design doc](./plans/2026-08-09-write-completion-correlation-design.md):
- **Pump-wait on the STA, not a parked reply.** The executor holds the STA
thread but pumps Windows messages each poll — the shipped ReadBulk pattern —
because commands serialize per session anyway, so freeing the STA during the
wait buys nothing and a parked reply would change the dispatcher/pipe
contracts.
- **Version baseline before the COM call** closes the fast-completion edge: a
callback that dispatches while `WriteSecured` is still on the stack still
correlates.
- **Timeout returns today's shape** (protocol OK, empty statuses):
unconfirmed is honest; a synthesized failure row would trigger consumer-side
write-revert logic on slow-but-successful commits. The 1.5 s default stays
inside OtOpcUa's 2 s Tier A write-resilience budget.
- **Parity preserved.** `protocol_status`/`hresult` keep describing
acceptance; the MXAccess outcome (success or failure) rides only in
`statuses[0]`; the `OnWriteComplete` event still streams unchanged (nothing
swallowed, nothing synthesized).
- **Scope: all four unary write kinds; bulk writes stay fire-and-forget.**
The first cut correlated `WriteSecured`/`WriteSecured2` only, but OtOpcUa's
dominant FreeAccess write path goes out as plain `Write` (2026-08-09 live
verification, 06/S-1) — a refused plain write was invisible on the reply.
Plain `Write`/`Write2` now correlate identically. Bulk writes keep
fire-and-forget replies: waiting per entry would add a device round-trip per
item to high-rate supervisory loops.
- **Best-effort correlation.** The callback carries only
`(hItem, statuses)` — no transaction id — so concurrent writes to the same
item within the window can swap rows; benign for the serialized single-write
consumer contract.
- **Client cancellation needs no special path**: a caller abandoning the RPC
mid-wait leaves the worker to finish its bounded wait and reply; the gateway
discards the reply, the session is never faulted.
## Later Revisit Items ## Later Revisit Items
These are explicit post-v1 revisit items, not open blockers: These are explicit post-v1 revisit items, not open blockers:
+10 -1
View File
@@ -114,6 +114,7 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. | | `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. |
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. | | `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. | | `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. |
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. | | `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. | | `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. | | `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
@@ -249,7 +250,7 @@ dev/test GLAuth posture (`glauth.md`), not a production posture.
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. | | `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. | | `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. | | `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, rotating it in `scadaproj/infra/glauth/` (and redeploying the shared GLAuth on `10.100.0.35`) is required — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover order. A blank/unresolved value fails startup validation with a message naming the two supported channels. | | `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, it was rotated in `scadaproj/infra/glauth/` (and the shared GLAuth on `10.100.0.35` redeployed) on 2026-08-07 (SEC-36) — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover procedure used, and for future rotations. A blank/unresolved value fails startup validation with a message naming the two supported channels. |
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. | | `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. | | `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). | | `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
@@ -273,6 +274,14 @@ staging rig, e.g. one pointed at the plaintext shared GLAuth). A production-like
deployment must therefore run with the literal `Production` environment name for deployment must therefore run with the literal `Production` environment name for
the hard-stops to apply. the hard-stops to apply.
`windev` (`10.100.0.48`) is deliberately labelled `Staging` (its NSSM
`DOTNET_ENVIRONMENT` entry, set 2026-08-07) rather than left at the `Production`
default. It is the permissive rig the parenthesis above describes: it runs
`Dashboard:DisableLogin=true` and binds the shared GLAuth, which offers no TLS,
so a `Production` label would contradict its own configuration and both
hard-stops would refuse the boot. Label a host `Production` only when its
configuration can satisfy them.
## Secrets Master Key ## Secrets Master Key
`${secret:...}` tokens in configuration — currently just `${secret:...}` tokens in configuration — currently just
+40 -24
View File
@@ -215,13 +215,21 @@ service described in `glauth.md`.
The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole` The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole`
set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific
dashboard-admin role and is **not** part of the five baseline GLAuth role dashboard-admin role and is **not** part of the baseline GLAuth role
groups — it must be provisioned before the LDAP live tests pass. groups — it must be provisioned before the LDAP live tests pass.
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips) `AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips)
when GLAuth has only the baseline groups, so this is a hard prerequisite when GLAuth has only the baseline groups, so this is a hard prerequisite
beyond "LDAP is up." See the "Adding a gw-specific group" section of beyond "LDAP is up." The shared directory
`glauth.md` for the provisioning step that adds `GwAdmin` and grants it to (`scadaproj/infra/glauth/config.toml`) already provisions `GwAdmin` (gid 5610)
`admin`. and `GwReader` (gid 5611); see the "Adding a gw-specific group" section of
`glauth.md` for the per-box equivalent.
The fixtures name real users from that shared config, so a run only proves the
service-account bind when it targets the shared directory. `appsettings.json`
ships `Server=localhost` for the local-forward case, so point the suite at the
shared GLAuth with `MxGateway__Ldap__Server=10.100.0.35`; the suite's
`AddEnvironmentVariables()` layer applies the override to the same
`MxGateway:Ldap` section production binds.
`DashboardAuthenticator` delegates the LDAP bind and group search to the shared `DashboardAuthenticator` delegates the LDAP bind and group search to the shared
`ZB.MOM.WW.Auth.Ldap` provider (`LdapAuthService`) and only maps the resulting `ZB.MOM.WW.Auth.Ldap` provider (`LdapAuthService`) and only maps the resulting
@@ -229,12 +237,17 @@ groups to dashboard roles via `DashboardGroupRoleMapper`; the bind/search
mechanics that decide each outcome live in that shared provider, not in mechanics that decide each outcome live in that shared provider, not in
`DashboardAuthenticator`. `DashboardAuthenticator`.
The suite covers both the success path and the failure outcomes: `admin` whose The suite covers both the success path and the failure outcomes: `admin`, whose
LDAP groups resolve to the `Admin` role succeeds and emits the role claim; `othergroups` include `GwAdmin`, succeeds and emits the role claim — this is the
`readonly` is denied because no group in their `memberOf` appears in one test that proves the service-account bind, because every other outcome below
`GroupToRole`; `admin` with a wrong password fails authentication without leaking fails identically whether or not the bind credential is right; `gw-viewer` is
the password into `FailureMessage`; an unknown username fails authentication; and denied because its only group (`GwReader`) is absent from `GroupToRole`, and its
an unreachable LDAP server is absorbed into a failed result rather than throwing. denial message must match the unknown-user denial so an authorization failure
cannot be used to enumerate valid accounts; `admin` with a wrong password fails
authentication without leaking the password into `FailureMessage`; an unknown
username fails authentication; and an unreachable LDAP server is absorbed into a
failed result rather than throwing. Both live users bind with the shared dev
password documented in `glauth.md`.
`appsettings.json` now ships the LDAP bind password as the unexpanded `appsettings.json` now ships the LDAP bind password as the unexpanded
`${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the `${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the
@@ -249,6 +262,7 @@ Run the LDAP live tests explicitly:
```bash ```bash
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1" $env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
$env:MxGateway__Ldap__Server = "10.100.0.35"
$env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>" $env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>"
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
``` ```
@@ -455,20 +469,22 @@ runtime because the x86 Worker cannot build on Linux:
### Runner capacity is shared and finite ### Runner capacity is shared and finite
All CI runs on a co-located `gitea-runner` container on docker host `10.100.0.35` with CI runs on two co-located runner containers on docker host `10.100.0.35` `gitea-runner`
`maxParallel=1`, and that runner instance is **shared across repos** — it interleaves (capacity 4) and `gitea-runner-2` (capacity 2, registered 2026-08-07 per
`dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs on the single slot rather than being `docs/runbooks/TST-30-second-ci-runner.md`) — and both runner instances are **shared across
scoped to this repo (`GET /repos/dohertj2/mxaccessgw/actions/runners` returns repos**: they interleave `dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs across the
`total_count: 0`; the runner is registered at the instance level). Every job in a run combined slots rather than being scoped to this repo (`GET
(`portable`, `java`, `windows-x86`) executes serially, so queue latency is additive within /repos/dohertj2/mxaccessgw/actions/runners` returns `total_count: 0`; both runners are
a run and an active `lmxopcua` run blocks `mxaccessgw` entirely — expect ~2030 minutes of registered at the instance level). Every job in a run (`portable`, `java`, `windows-x86`)
queue depth under cross-repo contention, not a stuck pipeline. This Gitea version (1.26) still executes serially within that run, so queue latency is additive within a run, but an
also exposes **no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel` active `lmxopcua` run no longer blocks `mxaccessgw` entirely the way a single shared slot
returns 404, `DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot did — the two runners relieve cross-repo contention. This Gitea version (1.26) also exposes
be cleared and holds the slot until it finishes or times out. See **no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel` returns 404,
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registers a second `DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot be cleared
runner to relieve this; until that lands, treat single-runner contention as expected, not a and holds the slot until it finishes or times out — with two runners this means a single
CI outage. wedged run can still hold slots, because the no-cancel reality is unchanged. See
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registered the
second runner.
When queue depth (or the missing-cancel reality) makes waiting impractical, verify a When queue depth (or the missing-cancel reality) makes waiting impractical, verify a
specific commit out of band instead of waiting behind the queue: run specific commit out of band instead of waiting behind the queue: run
@@ -0,0 +1,169 @@
# WriteSecured Completion Correlation — Design
Date: 2026-08-09
Requested by: OtOpcUa (archreview finding 06/S-1, cross-repo residual — gateway half)
Status: approved for implementation (peer contract confirmed over cross-session message)
## Problem
The unary `Invoke` reply for a `WriteSecured` / `WriteSecured2` command proves worker-side
command *acceptance* only. MXAccess writes are fire-and-forget at the toolkit level: the
real per-item outcome arrives later in the `OnWriteComplete` COM callback. Today the worker
returns `CreateOkReply` immediately after the COM call, so `MxCommandReply.statuses` is
always empty and consumers (OtOpcUa's `GatewayGalaxyDataWriter.TranslateReply`) must treat
every write as provisionally good (`galaxy.writes.unconfirmed` meter).
Consumer contract (already shipped OtOpcUa-side): `statuses.Count > 0` → map `statuses[0]`
through the MX status map to a real OPC UA StatusCode; empty → provisional Good +
unconfirmed meter. The `statuses` field already exists on `MxCommandReply` (field 7); no
proto shape change is needed.
## Approaches Considered
1. **Executor pump-wait + versioned completion cache (chosen).** After the `WriteSecured`
COM call, the executor holds the STA thread but explicitly pumps Windows messages each
poll iteration until the matching completion is recorded or a bounded deadline passes.
This is exactly the shipped `ReadBulk` pattern (`MxAccessValueCache.TryWaitForUpdate` +
`pumpStep``StaRuntime.PumpPendingMessages()`), so it adds no new threading model.
2. **Parked asynchronous reply.** Executor returns a "pending" marker; the dispatcher parks
the correlation and the pipe reply is written later from the event dispatch. Rejected:
changes the `IStaCommandExecutor`/dispatcher/pipe contracts for no real gain — commands
serialize per session anyway, so freeing the STA during the wait buys nothing.
3. **Gateway-side correlation.** Gateway watches the session event stream for the
`OnWriteComplete` after the worker reply. Rejected: races the event drain cadence,
couples the gateway to event semantics, still holds the unary RPC, and spreads the
feature across two processes.
## Design
All changes are worker-side (`ZB.MOM.WW.MxGateway.Worker`, net48 x86). The gateway's
`Invoke` already forwards the worker `MxCommandReply` (statuses included) verbatim.
### New: `MxAccessWriteCompletionCache`
Mirror of `MxAccessValueCache`, keyed by `(serverHandle, itemHandle)` (packed long), one
entry per key holding the most recent completion's `RepeatedField<MxStatusProxy>` (cloned)
plus a monotonically increasing per-key `Version`. API:
- `Record(int serverHandle, int itemHandle, RepeatedField<MxStatusProxy> statuses)`
- `ulong CurrentVersion(int serverHandle, int itemHandle)` — 0 when absent
- `bool TryWaitForCompletion(int serverHandle, int itemHandle, ulong sinceVersion,
DateTime deadlineUtc, Action pumpStep, out RepeatedField<MxStatusProxy> statuses,
int pollIntervalMs = 5)` — pump/poll loop identical in shape to
`MxAccessValueCache.TryWaitForUpdate`.
Same locking posture as the value cache: everything runs on the STA thread; a sync root
keeps it nominally thread-safe for tests.
### Sink: record completions
`MxAccessBaseEventSink` owns a `MxAccessWriteCompletionCache` (new optional ctor param,
exposed as a property) and its `OnWriteComplete` handler records into it via the existing
`EnqueueEvent` post-publish hook (same pattern as the value cache on `OnDataChange`):
the streamed `MxEvent` is built exactly once by the mapper, enqueued unchanged for the
event stream, and its `Statuses` are then recorded into the cache. The event stream is
not altered — nothing is swallowed or synthesized.
A new seam interface `IWriteCompletionCacheProvider { MxAccessWriteCompletionCache
WriteCompletionCache { get; } }` is implemented by `MxAccessBaseEventSink` and by test
sinks. `MxAccessSession.Create` pulls the cache from the sink through that interface
(fallback: fresh instance), mirroring the existing `ValueCache` sharing, and exposes it
on the session.
### Executor: bounded pump-wait
In `ExecuteWriteSecured` / `ExecuteWriteSecured2`:
1. Capture `baseline = cache.CurrentVersion(serverHandle, itemHandle)` **before** the COM
call — this closes the fast-completion ordering edge: a callback that dispatches during
or immediately after the COM call bumps the version past the baseline and still
correlates.
2. Call `session.WriteSecured(...)` as today.
3. `cache.TryWaitForCompletion(..., baseline, deadline, pumpStep, out statuses)`; on
success, `reply.Statuses.Add(statuses)`; on timeout, return the reply exactly as today
(protocol OK, empty statuses) — the consumer's honest-unconfirmed path. No invented
failure rows.
The reply's `ProtocolStatus`/`Hresult` stay untouched by the completion outcome: the
command was accepted; the MX outcome (success *or* failure) is carried only in
`statuses[0]`. That preserves MXAccess parity (the native API returns void; the outcome
exists only in the callback) while enriching the reply with information that was already
on the wire.
Timeout default: **1.5 s** (`MxAccessCommandExecutor.DefaultWriteCompletionTimeout`).
The consumer-side budget drives this: OtOpcUa wraps the driver write in a Tier A
resilience policy with a 2 s timeout and a 5-failure breaker, so a worker wait longer
than 2 s would convert slow-but-successful commits into consumer-side false failures
(node revert + breaker pressure). 1.5 s covers the common fast-commit case and degrades
a slow commit to the honest-unconfirmed path instead. It also sits well under the
gateway's 30 s `DefaultCommandTimeoutSeconds` IPC wait and the STA watchdog's 75 s
dispatched-command ceiling.
The wait is deployment-configurable end to end, following the existing
pipe-connect-timeout pattern: a new gateway option
`MxGateway:Worker:WriteCompletionWaitMilliseconds` (default `1500`, validated `>= 0`;
`0` disables the wait and restores pure fire-and-forget replies) is exported by
`WorkerProcessLauncher` as the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment
variable, which the worker reads at session construction. A deployment that raises the
gateway wait must raise the OtOpcUa driver's Write `ResilienceConfig` timeout in step
(per-instance operator config on the OtOpcUa side) — documented in
`docs/GatewayConfiguration.md`. `MxAccessStaSession` additionally gets an internal
`WriteCompletionTimeout` seam (read when it constructs the executor in `StartAsync`) so
tests can shorten it without env plumbing.
Client cancellation mid-wait needs no new code path, only verification: when the caller
cancels the unary RPC, the gateway abandons its IPC reply wait, but the worker command
is already in flight — `CancelCommand` only dequeues *queued* commands. The executor
simply finishes its bounded wait and replies; the gateway discards the reply. The
session is never faulted and the `OnWriteComplete` event still flows on the stream.
### Scope
- **In**: `WriteSecured`, `WriteSecured2` (single-item secured writes — inherently
low-rate operator actions, and exactly the OtOpcUa single-write contract). Default-on.
- **Out**: plain `Write`/`Write2` and all bulk write commands stay fire-and-forget —
waiting would add a device round-trip of latency to high-rate supervisory write loops.
- **Correlation fidelity is best-effort**: the MXAccess callback carries only
`(hItem, statuses)` — no transaction id — so a concurrent write to the same item within
the wait window can be attributed to the wrong writer (worst case two writes to the
same item swap status rows — benign for the serialized single-write consumer
contract). Documented on the proto field.
## Error handling
- Completion never arrives (device down): bounded 1.5 s wait, then today's reply shape.
- Event queue overflow during completion: the queue records a fault and the fail-fast
design tears the session down; the post-publish hook not firing in that case is moot.
- COM call throws: unchanged — the dispatcher's existing exception path replies with the
native HResult; no wait is entered.
## Testing (Worker.Tests, x86 — verified on windev)
- `MxAccessWriteCompletionCacheTests`: record/version monotonicity, wait success,
deadline expiry, baseline-before-record fast-completion ordering.
- `MxAccessBaseEventSinkTests`: `OnWriteComplete` both enqueues the event *and* records
the completion; cache instance is the sink-bound one.
- `MxAccessCommandExecutorTests` (via `MxAccessStaSession.DispatchAsync` + fake COM
object + test sink implementing `IWriteCompletionCacheProvider`):
- completion recorded synchronously inside the fake's `WriteSecured` (fast edge) →
reply carries `statuses[0]`;
- completion recorded from the test thread while the executor pump-waits → reply
carries `statuses[0]`;
- no completion + shortened timeout → protocol OK, empty statuses;
- `WriteSecured2` mirrors; plain `Write` does not wait.
- Gateway tests (macOS-runnable): `GatewayOptionsValidator` accepts `>= 0` and rejects
negative `WriteCompletionWaitMilliseconds`; `WorkerProcessLauncher` exports
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` from the option (mirroring the existing
pipe-connect-timeout env assertion).
## Docs in the same change
- `mxaccess_gateway.proto`: comment on `MxCommandReply.statuses` and the
`WriteSecuredCommand`/`WriteSecured2Command` messages describing the correlated
completion contract (populated within the wait window; empty = unconfirmed;
best-effort correlation). Comment-only → wire-identical; regenerate + commit
`Contracts/Generated` (required for the C# build); other clients' generated code is
functionally unchanged.
- `docs/GatewayConfiguration.md`: the new `MxGateway:Worker:WriteCompletionWaitMilliseconds`
option, including the pairing rule with the OtOpcUa driver's Write resilience timeout.
- `gateway.md` command/event surface note; `docs/DesignDecisions.md` entry.
@@ -0,0 +1,575 @@
# WriteSecured Completion Correlation Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Populate `MxCommandReply.statuses[0]` on unary `WriteSecured`/`WriteSecured2` replies with the correlated MXAccess `OnWriteComplete` outcome, bounded by a configurable wait (default 1.5 s), falling back to today's empty-statuses shape on timeout.
**Architecture:** Worker-side only (plus one gateway config option). A versioned per-`(serverHandle, itemHandle)` completion cache (mirror of `MxAccessValueCache`) is populated by the event sink's `OnWriteComplete` post-publish hook; the STA command executor captures a version baseline before the COM call, then pump-waits (ReadBulk precedent) until a newer completion lands or the deadline passes. Design: `docs/plans/2026-08-09-write-completion-correlation-design.md`.
**Tech Stack:** .NET Framework 4.8 x86 worker (no init-only props/positional records!), .NET 10 gateway, protobuf via Grpc.Tools regen. Worker builds/tests run ONLY on windev (10.100.0.48) — local macOS verification covers the gateway + contracts.
---
### Task 0: Create feature branch
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
```bash
cd /Users/dohertj2/Desktop/MxAccessGateway && git checkout -b feat/write-completion-correlation
```
### Task 1: Proto contract comments + regen
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (everything builds on the regenerated contracts)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (~line 528 `statuses`, ~line 259 `WriteSecuredCommand`, ~line 269 `WriteSecured2Command`)
- Regenerate: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs`
**Step 1:** On `repeated MxStatusProxy statuses = 7;` in `MxCommandReply`, add above the field:
```proto
// Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
// replies the worker holds the reply for a bounded window (default 1.5 s,
// MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
// MXAccess OnWriteComplete callback and copies its status rows here, so
// statuses[0] carries the real MXAccess commit outcome (success OR failure)
// while protocol_status/hresult still describe command acceptance only.
// Empty statuses on a write reply means the completion did not arrive
// within the window — the write is unconfirmed, not failed. Correlation is
// best-effort per (server_handle, item_handle): MXAccess's callback carries
// no transaction id, so concurrent writes to the same item within the
// window can swap rows. The OnWriteComplete event still flows on the event
// stream unchanged. Other command kinds leave this field as before.
```
**Step 2:** On `message WriteSecuredCommand` and `message WriteSecured2Command`, append to the existing leading comment (or add one): `// The unary reply's statuses field carries the correlated OnWriteComplete outcome when it arrives within the worker's bounded wait — see MxCommandReply.statuses.`
**Step 3:** Regenerate + verify wire-identical build:
```bash
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
```
Expected: build succeeds, `git diff --stat` shows only comment-churn in Generated.
**Step 4:** Commit: `git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto src/ZB.MOM.WW.MxGateway.Contracts/Generated && git commit -m "docs(proto): document the correlated write-completion statuses contract"`
(Comment-only proto change is wire-identical; other clients' generated code is intentionally not regenerated — no functional delta.)
### Task 2: MxAccessWriteCompletionCache + tests
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs`
- Create: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs`
**Step 1:** Create the cache — mirror `MxAccessValueCache`'s shape, locking, and net48 constraints (no init-only, plain struct/class):
```csharp
using System;
using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session cache of the most recent <c>OnWriteComplete</c> status rows
/// for each (server handle, item handle) pair. Written by the MXAccess
/// event sink as completion callbacks arrive; read by the write command
/// executor so a WriteSecured/WriteSecured2 reply can carry the correlated
/// MXAccess outcome instead of proving command acceptance only.
/// </summary>
/// <remarks>
/// Same threading posture as <see cref="MxAccessValueCache"/>: writers and
/// readers run on the worker's STA thread (COM dispatches events on the
/// apartment thread; commands also execute on the STA), so no internal
/// locking is required. A single sync root keeps it nominally thread-safe
/// for tests that drive it from a non-STA thread.
/// </remarks>
public sealed class MxAccessWriteCompletionCache
{
private readonly Dictionary<long, CompletionEntry> entries = new();
private readonly object syncRoot = new();
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="statuses">Status rows from the mapped OnWriteComplete event; cloned before storing.</param>
public void Record(
int serverHandle,
int itemHandle,
RepeatedField<MxStatusProxy> statuses)
{
if (statuses is null)
{
throw new ArgumentNullException(nameof(statuses));
}
lock (syncRoot)
{
long key = CreateItemKey(serverHandle, itemHandle);
ulong version = entries.TryGetValue(key, out CompletionEntry existing)
? existing.Version + 1
: 1UL;
entries[key] = new CompletionEntry(version, statuses.Clone());
}
}
/// <summary>Returns the current completion version for a handle pair, or 0 if none was recorded.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <returns>The current completion version, or 0 if no completion was recorded.</returns>
public ulong CurrentVersion(
int serverHandle,
int itemHandle)
{
lock (syncRoot)
{
return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing)
? existing.Version
: 0UL;
}
}
/// <summary>
/// Polls for a completion newer than <paramref name="sinceVersion"/> until it
/// arrives or the deadline elapses, calling <paramref name="pumpStep"/> on every
/// poll iteration so the worker's STA can dispatch the inbound MXAccess
/// OnWriteComplete message. Same loop shape as
/// <see cref="MxAccessValueCache.TryWaitForUpdate"/>.
/// </summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="sinceVersion">Version snapshot captured before the write COM call.</param>
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
/// <param name="statuses">The recorded status rows if a completion arrived before the deadline.</param>
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
/// <returns><see langword="true"/> if a completion newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
public bool TryWaitForCompletion(
int serverHandle,
int itemHandle,
ulong sinceVersion,
DateTime deadlineUtc,
Action pumpStep,
out RepeatedField<MxStatusProxy> statuses,
int pollIntervalMs = 5)
{
if (pumpStep is null)
{
throw new ArgumentNullException(nameof(pumpStep));
}
while (true)
{
pumpStep();
lock (syncRoot)
{
if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry)
&& entry.Version > sinceVersion)
{
statuses = entry.Statuses;
return true;
}
}
if (DateTime.UtcNow >= deadlineUtc)
{
statuses = new RepeatedField<MxStatusProxy>();
return false;
}
Thread.Sleep(pollIntervalMs);
}
}
private static long CreateItemKey(
int serverHandle,
int itemHandle)
{
return ((long)serverHandle << 32) | (uint)itemHandle;
}
/// <summary>
/// Snapshot of the most recent OnWriteComplete status rows for a handle
/// pair. <see cref="Version"/> increments by one on every
/// <see cref="Record"/> call so the write executor can detect "a new
/// completion arrived since I captured my baseline".
/// </summary>
/// <remarks>
/// Plain readonly struct (not a record) so this compiles under the
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
/// </remarks>
private readonly struct CompletionEntry
{
public CompletionEntry(
ulong version,
RepeatedField<MxStatusProxy> statuses)
{
Version = version;
Statuses = statuses;
}
public ulong Version { get; }
public RepeatedField<MxStatusProxy> Statuses { get; }
}
}
```
**Step 2:** Tests (mirror `MxAccessValueCacheTests` style; build `MxStatusProxy` rows inline):
- `Record_IncrementsVersionPerKey` — two `Record` calls on the same pair → `CurrentVersion` 1 then 2; a different pair stays independent.
- `TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses` — record once, wait with `sinceVersion: 0`, deadline in the future → `true`, statuses round-trip (assert an `MxStatusProxy` field value survives the clone).
- `TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut` — record once, wait with `sinceVersion: CurrentVersion(...)` and a deadline ~50 ms out → `false`, out statuses empty.
- `TryWaitForCompletion_InvokesPumpStepEachIteration` — pumpStep increments a counter; on the counter's second call, `Record` the completion (this proves the pump loop is what lets the callback land); assert `true` and counter >= 2.
- `Record_ClonesStatuses` — mutate the caller's `RepeatedField` after `Record`; waited-out statuses unaffected.
**Step 3:** Cannot compile locally (worker is Windows-only) — defer build/test to Task 9 (windev). Commit: `git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs && git commit -m "feat(worker): versioned OnWriteComplete completion cache"`
### Task 3: Sink records completions (+ provider seam)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs`
**Step 1:** New seam interface:
```csharp
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Exposes the per-session <see cref="MxAccessWriteCompletionCache"/> an
/// event sink populates from OnWriteComplete callbacks, so
/// <see cref="MxAccessSession.Create"/> can share one instance between the
/// sink (writer) and the write command executor (reader). Implemented by
/// <see cref="MxAccessBaseEventSink"/> and by test sinks that cannot
/// attach to a live MXAccess COM object.
/// </summary>
public interface IWriteCompletionCacheProvider
{
/// <summary>The completion cache bound to this sink.</summary>
MxAccessWriteCompletionCache WriteCompletionCache { get; }
}
```
**Step 2:** `MxAccessBaseEventSink` — declare `IWriteCompletionCacheProvider` on the class; add a `private readonly MxAccessWriteCompletionCache writeCompletionCache;` initialized in the widest ctor (add a new optional-most ctor overload following the existing chain pattern: the 3-arg `(eventQueue, eventMapper, valueCache)` ctor chains to a new 4-arg `(eventQueue, eventMapper, valueCache, writeCompletionCache)` with a fresh cache); expose `public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;`. Change `OnWriteComplete` to use the post-publish hook (same pattern as `OnDataChange`'s value-cache publish — post-publish only runs after the event cleared the queue, and a queue overflow faults the session anyway):
```csharp
MXSTATUS_PROXY[] statuses = pVars;
EnqueueEvent(
() => eventMapper.CreateOnWriteComplete(
sessionId,
hLMXServerHandle,
phItemHandle,
statuses),
mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses));
```
**Step 3:** Tests in `MxAccessBaseEventSinkTests` (mirror `OnDataChange_ComCallback_PopulatesValueCache` and `ValueCache_ReturnsTheInstanceBoundAtConstruction`):
- `OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent` — drive `sink.OnWriteComplete(7, 21, ref proxies)`; assert the queue got the OnWriteComplete event AND `cache.CurrentVersion(7, 21) == 1`.
- `WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction`.
**Step 4:** Commit: `git commit -m "feat(worker): event sink records OnWriteComplete rows into the completion cache"` (explicit paths).
### Task 4: MxAccessSession plumbing
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs` (private ctor ~line 18, `Create` ~line 145)
**Step 1:** Add `private readonly MxAccessWriteCompletionCache writeCompletionCache;` + ctor param (after `valueCache`) + null guard; add property mirroring `ValueCache`:
```csharp
/// <summary>
/// Per-session OnWriteComplete completion cache populated by the event
/// sink. The write command executor consults it after a
/// WriteSecured/WriteSecured2 COM call so the unary reply can carry
/// the correlated completion outcome.
/// </summary>
public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;
```
**Step 2:** In `Create`, next to the value-cache sharing block:
```csharp
// Share the sink's completion cache the same way (production sink
// and completion-aware test sinks implement the provider seam);
// fall back to a fresh cache for other fakes — the write executor
// then simply never observes a completion and replies unconfirmed.
MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider
? provider.WriteCompletionCache
: new MxAccessWriteCompletionCache();
```
Pass it to the ctor.
**Step 3:** Commit: `git commit -m "feat(worker): share the completion cache between sink and session"`.
### Task 5: Executor bounded wait + StaSession env plumbing
**Classification:** high-risk (STA/pump semantics)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (touches the same files as 6's tests exercise)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (~lines 14-85 ctors, 447-497 write methods)
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (~line 205 executor construction)
**Step 1:** Executor — next to `DefaultReadBulkTimeout`:
```csharp
/// <summary>
/// Default bounded wait for the OnWriteComplete callback after a
/// WriteSecured/WriteSecured2 COM call. 1.5 s keeps the unary reply
/// inside the OtOpcUa driver's 2 s Tier A write-resilience budget (a
/// longer gateway wait must raise that consumer timeout in step) while
/// covering the common fast-commit case; on expiry the reply returns
/// with empty statuses — unconfirmed, not failed.
/// </summary>
internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500);
private readonly TimeSpan writeCompletionTimeout;
```
Add `TimeSpan? writeCompletionTimeout = null` as a trailing optional parameter on the widest (4-arg) ctor; `this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;`.
**Step 2:** In `ExecuteWriteSecured`, replace the tail (`session.WriteSecured(...); return CreateOkReply(command);`) with:
```csharp
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
// Baseline BEFORE the COM call: a completion that dispatches during or
// immediately after WriteSecured bumps the version past this snapshot,
// so a fast commit still correlates (no missed-callback window).
ulong completionBaseline = completionCache.CurrentVersion(
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle);
session.WriteSecured(
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle,
writeSecuredCommand.CurrentUserId,
writeSecuredCommand.VerifierUserId,
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
MxCommandReply reply = CreateOkReply(command);
AwaitWriteCompletion(
reply,
completionCache,
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle,
completionBaseline);
return reply;
```
Mirror in `ExecuteWriteSecured2`. Shared private helper:
```csharp
/// <summary>
/// Bounded pump-wait for the OnWriteComplete row matching a
/// WriteSecured/WriteSecured2 call, copied onto the reply when it
/// arrives in time. The executor holds the STA thread but pumps
/// Windows messages each poll (ReadBulk precedent) so the COM callback
/// can dispatch re-entrantly; on expiry the reply keeps its empty
/// statuses — the consumer's unconfirmed path, never a synthesized
/// failure. Protocol status/hresult stay acceptance-only either way.
/// </summary>
private void AwaitWriteCompletion(
MxCommandReply reply,
MxAccessWriteCompletionCache completionCache,
int serverHandle,
int itemHandle,
ulong completionBaseline)
{
if (writeCompletionTimeout <= TimeSpan.Zero)
{
return;
}
if (completionCache.TryWaitForCompletion(
serverHandle,
itemHandle,
completionBaseline,
DateTime.UtcNow + writeCompletionTimeout,
pumpStep,
out Google.Protobuf.Collections.RepeatedField<MxStatusProxy> statuses))
{
reply.Statuses.Add(statuses);
}
}
```
**Step 3:** `MxAccessStaSession` — add:
```csharp
/// <summary>
/// Environment variable the gateway's WorkerProcessLauncher sets from
/// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the
/// write-completion wait (pure fire-and-forget replies).
/// </summary>
internal const string WriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
/// <summary>
/// Bounded WriteSecured/WriteSecured2 completion wait handed to the
/// command executor at StartAsync. Internal-settable as a test seam so
/// Worker.Tests can shorten it without env-var plumbing.
/// </summary>
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
internal static TimeSpan ResolveWriteCompletionTimeout()
{
string value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName);
return int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int milliseconds)
&& milliseconds >= 0
? TimeSpan.FromMilliseconds(milliseconds)
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
}
```
and pass `writeCompletionTimeout: WriteCompletionTimeout` when constructing `MxAccessCommandExecutor` in `StartAsync`. (net48: `Environment.GetEnvironmentVariable` returns `string` — keep nullable annotations consistent with the file.)
**Step 4:** Commit: `git commit -m "feat(worker): bounded pump-wait correlates OnWriteComplete onto secured-write replies"`.
### Task 6: Executor tests
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 2-5)
**Files:**
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs`
**Step 1:** Test support inside the test class file:
- New sink: `private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider { public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache(); public void Attach(object mxAccessComObject, string sessionId) { } public void Detach() { } }` (match the exact `IMxAccessEventSink` member list — read the interface first).
- `FakeMxAccessComObject`: add `public Action OnWriteSecuredCallback { get; set; }` (nullable per file convention) invoked at the end of `WriteSecured` and `WriteSecured2`.
**Step 2:** Tests (all through `MxAccessStaSession.DispatchAsync`, constructing the session with the completion sink; set `session.WriteCompletionTimeout` before `StartAsync`):
- `DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses` (fast-completion edge): wire `OnWriteSecuredCallback = () => sink.WriteCompletionCache.Record(82, 821, StatusRows(1))` (helper building a `RepeatedField<MxStatusProxy>` with a recognizable value); dispatch; assert `ProtocolStatus.Code == Ok`, `reply.Statuses.Count == 1`, row round-trips.
- `DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses`: no fake callback; `WriteCompletionTimeout = TimeSpan.FromSeconds(10)`; start `Task<MxCommandReply> pending = session.DispatchAsync(...)`, then `sink.WriteCompletionCache.Record(...)` from the test thread after a short `Task.Delay(50)`; await; assert statuses present. (No `.ConfigureAwait(false)` in `[Fact]` bodies — xUnit1030 fails the Windows build.)
- `DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol`: `WriteCompletionTimeout = TimeSpan.FromMilliseconds(100)`; assert Ok + `reply.Statuses.Count == 0`.
- `DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses` (mirror of the fast test).
- `DispatchAsync_Write_DoesNotWaitForCompletion`: `WriteCompletionTimeout = TimeSpan.FromSeconds(30)`, plain `Write` command, no completion recorded; assert `await` completes within a 5 s guard (`Task.WhenAny` with `Task.Delay`) — proving plain writes never enter the wait.
- Baseline test `DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall`: `Record` once BEFORE dispatch, `WriteCompletionTimeout = 100 ms`, no new completion → empty statuses (stale row not misattributed).
**Step 2b:** Confirm the two existing WriteSecured tests (`DispatchAsync_WriteSecured_ForwardsUserIds`, `..._WriteSecured2_...`) still pass unmodified — they use `NoopEventSink`, so the session falls back to a fresh cache, no completion ever arrives, and the default 1.5 s wait adds latency only; if that latency bothers the suite, switch them to the completion sink with `WriteCompletionTimeout = TimeSpan.Zero`.
**Step 3:** Commit: `git commit -m "test(worker): write-completion correlation executor coverage"`.
### Task 7: Gateway config option + launcher env var
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3, Task 4
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (~line 224 block)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs` (~lines 18-22 consts, ~line 175 env block)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs`, the `GatewayOptionsValidator` test file (find via `grep -rl GatewayOptionsValidatorTests src/ZB.MOM.WW.MxGateway.Tests`)
**Step 1:** `WorkerOptions`:
```csharp
/// <summary>
/// Bounded wait, in milliseconds, the worker holds a WriteSecured/WriteSecured2
/// reply for the matching MXAccess OnWriteComplete callback so the reply's
/// statuses carry the real commit outcome. 0 disables the wait. Deployments
/// raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A
/// write resilience timeout) must raise those in step.
/// </summary>
public int WriteCompletionWaitMilliseconds { get; init; } = 1500;
```
**Step 2:** Validator (>= 0, not the positive helper):
```csharp
if (options.WriteCompletionWaitMilliseconds < 0)
{
builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
}
```
**Step 3:** Launcher — const `public const string WorkerWriteCompletionWaitEnvironmentVariableName = "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";` and in `CreateStartInfo` next to the pipe-connect env line:
```csharp
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
```
**Step 4:** Tests — mirror the existing pipe-connect-timeout launcher env assertion and an existing validator negative test; add: launcher exports `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS=1500` by default / custom value when configured; validator rejects `-1`, accepts `0`.
**Step 5:** Run locally:
```bash
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayOptionsValidator"
```
Expected: PASS.
**Step 6:** Commit: `git commit -m "feat(gateway): configurable worker write-completion wait (MxGateway:Worker:WriteCompletionWaitMilliseconds)"`.
### Task 8: Docs
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (write after code settles)
**Files:**
- Modify: `docs/GatewayConfiguration.md` (Worker options table, ~line 113 area)
- Modify: `gateway.md` (command surface / write semantics section)
- Modify: `docs/DesignDecisions.md` (new decision entry)
Content: the option row (default 1500, 0 disables, consumer-budget pairing rule with OtOpcUa's Write resilience timeout); gateway.md note that WriteSecured/WriteSecured2 unary replies now carry correlated completion statuses (bounded wait, empty = unconfirmed, event stream unchanged); DesignDecisions entry summarizing the design doc (link it) including the best-effort per-(hItem) correlation caveat and why plain Write/Write2/bulk stay fire-and-forget. Follow `docs/style-guides/StyleGuide.md` (present tense, why not what).
Commit: `git commit -m "docs: write-completion correlation configuration and semantics"`.
### Task 9: Local + windev verification
**Classification:** standard
**Estimated implement time:** ~10 min (mostly remote build time)
**Parallelizable with:** none
**Step 1 (local):** `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` → succeeds; re-run the Task 7 filtered gateway tests.
**Step 2 (push):** `git push -u origin feat/write-completion-correlation`.
**Step 3 (windev):** Use the isolated `C:\build` worktree (NOT the Desktop checkout — dirty feature branch). Remote PS via base64 `-EncodedCommand`; no `$ErrorActionPreference='Stop'` around git. Sequence:
```
git fetch origin && git checkout feat/write-completion-correlation && git pull
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~MxAccessWriteCompletionCacheTests|FullyQualifiedName~MxAccessBaseEventSinkTests|FullyQualifiedName~MxAccessCommandExecutorTests"
```
Expected: build clean (TreatWarningsAsErrors — watch xUnit1030), all filtered tests PASS. Fix-and-push iterations happen from the Mac; windev only builds/tests.
**Step 4:** Full worker test suite once green on the filter (`dotnet test ... -p:Platform=x86`, no filter) — one full pass before merge.
### Task 10: Review, merge, notify
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
- Run a code review over the full branch diff (code-reviewer agent or /code-review) and address findings.
- Merge: `git checkout main && git merge --no-ff feat/write-completion-correlation && git push origin main`.
- Notify the OtOpcUa session (SendMessage) that the feature is on `main` and built/tested on windev; flag that the live ArchestrA leg needs the user's Windows deployment (wonder-app-vd03 / 10.100.0.48 redeploy is a separate user-approved step).
- Surface to the user: deployed services (`MxAccessGw` on 10.100.0.48, wonder-app-vd03) do NOT pick this up until redeployed; OtOpcUa's end-to-end verification against a live gateway needs that redeploy.
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-08-09-write-completion-correlation.md",
"tasks": [
{"id": 0, "subject": "Task 0: Create feature branch", "status": "pending"},
{"id": 1, "subject": "Task 1: Proto contract comments + regen", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: MxAccessWriteCompletionCache + tests", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Sink records completions (+ provider seam)", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: MxAccessSession plumbing", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Executor bounded wait + StaSession env plumbing", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Executor tests", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Gateway config option + launcher env var", "status": "pending", "blockedBy": [0]},
{"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [6, 7]},
{"id": 9, "subject": "Task 9: Local + windev verification", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 10: Review, merge, notify", "status": "pending", "blockedBy": [9]}
],
"lastUpdated": "2026-08-09"
}
@@ -1,5 +1,57 @@
# SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook) # SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
> **Executed 2026-08-07 — the rotation is done; this runbook is now history plus the four
> corrections below.** A new service-account password was generated, `scadaproj/infra/glauth/config.toml`'s
> `serviceaccount` `passsha256` was replaced and the shared GLAuth recreated, and the old value
> (the literal this repo committed, live in the directory since 2026-06-04) no longer binds. The new
> value now exists only in the
> three channels this runbook names: the GLAuth `passsha256` (committed in `scadaproj`), the NSSM
> service environment on `10.100.0.48`, and this dev Mac's user-secrets. The retired plaintext was
> also scrubbed from `scadaproj/infra/glauth/`'s `config.toml`/`docker-compose.yml`/`README.md`
> comments and from the host's live `docker-compose.yml` (the `*.bak-sec36` backups on the host still
> carry it, deliberately — they are the rollback artifacts).
>
> **Correction 1 — host paths in step 3 were stale.** The runbook says
> `cd ~/Desktop/scadaproj/infra/glauth` on `10.100.0.35`. That directory does not exist there:
> `scadaproj` is a dev-workstation checkout, and the docker host runs the stack from
> **`/home/dohertj2/zb-glauth`** (container **`zb-shared-glauth`**, project name `zb-shared-glauth`).
> The repo remains the source of truth; deployment is the `scp` of `config.toml`/`docker-compose.yml`
> into `~/zb-glauth` documented in `scadaproj/infra/glauth/README.md`, followed by
> `docker compose up -d --force-recreate` there.
>
> **Correction 2 — `wonder-app-vd03` is out of scope, on documentary evidence.** The precondition
> above says to check `MxGateway:Ldap:Enabled` on that host. It could not be checked directly (the
> host is unreachable from the dev network), but it is out of scope regardless: its gateway binds a
> **different directory** — the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`,
> not `dc=zb,dc=local` — so this credential is not one it can hold. No env var was staged there and
> none is needed.
>
> **Correction 3 — step 4's dashboard verification is deferred on `10.100.0.48`; a direct bind was
> used instead.** The NEW value **is** staged on windev (added as the 10th `AppEnvironmentExtra`
> entry on the `MxAccessGw` NSSM service), but dashboard `/login` could not exercise it at rotation
> time: windev's gateway was **crash-looping on a pre-existing, unrelated fault** — the deployed Server binary
> (2026-06-25) predates the auth-DB migration of 2026-07-15, so it opens a schema-version-3 database
> it only supports at version 2 and aborts at startup (~10k Hosting-failed events/day since at least
> 08-06). That was a stale-deployment problem, not a rotation problem; it was filed as next-cycle
> finding NEXT-07 and resolved by redeploy on 2026-08-07. **Verification used instead:** a direct `ldapsearch` bind as
> `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and
> returned the `multi-role` entry — which is precisely the search bind the dashboard performs, minus
> the HTTP shell. **The deferred check was completed 2026-08-07**, once windev was repaired by the
> redeploy filed under NEXT-07. With `Dashboard:DisableLogin=false` supplied as a process-env-only
> override on a foreground run of the new build, `GET /login` returned 200 with an antiforgery token,
> `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard`
> cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to
> `/login?ReturnUrl=%2F` — so the rotated credential is proven through the real
> `DashboardAuthenticator` search-bind path on the deployed host. As deployed, windev keeps
> `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression
> proof is `DashboardLdapLiveTests` (5/5 green against `10.100.0.35` since commit `de67b45`).
>
> **Correction 4 — the lockout caution under "Verifying the rotation" is inert for this instance.**
> It warns that GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office when testing
> that the old value is dead. This GLAuth runs `LimitFailedBinds = false` (`config.toml:14`), so no
> failed-bind limiter is active and the caution does not apply here. Keep the caution for any
> instance that enables the limiter.
Operator steps to rotate the shared GLAuth service-account password after the repo-side Operator steps to rotate the shared GLAuth service-account password after the repo-side
removal landed (SEC-36). The repo change (removal of the committed value, the two supported removal landed (SEC-36). The repo change (removal of the committed value, the two supported
secret channels, and this runbook) is already merged; the live rotation below is the secret channels, and this runbook) is already merged; the live rotation below is the
+35
View File
@@ -1,5 +1,40 @@
# TST-30 — Register A Second CI Runner (Operator Runbook) # TST-30 — Register A Second CI Runner (Operator Runbook)
> **Executed 2026-08-07 — option (a) shipped; this runbook is now history plus the one
> correction below.** `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/
> `ubuntu-22.04`) runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same
> `container.network: traefik` setting as the original; its registration token is mounted from a
> `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was
> left untouched, so capacity went 4 → 6 by addition and the change reverts by removing one
> container. Concurrency was verified by pushing HEAD to two scratch branches while an unrelated
> run was in flight: jobs from three runs ran simultaneously across both runners, and a
> `gitea-runner-2` job cloned successfully from `http://gitea:3000` (the property option (c) was
> rejected for losing).
>
> **Correction to the Verification and Done-criteria sections below:** they expect
> `GET /repos/dohertj2/mxaccessgw/actions/runners` to show ≥2 runners. It does not — it still
> returns `total_count: 0`, correctly, because both runners are registered at the **instance**
> level, which is the very condition the "Why" section describes. Use
> `GET /api/v1/admin/actions/runners` instead (it now lists only id 1 and id 5; id 4 was removed
> 2026-08-07 — a local macOS `act_runner` mislabelled `ubuntu-latest`/`ubuntu-22.04`/`ubuntu-20.04`,
> so it captured Linux-labelled jobs it had no Docker daemon to run and failed them. Its config and
> registration are kept disabled at `~/gitea-act-runner.disabled-2026-08-07` (launchd plist at
> `~/Library/LaunchAgents/com.dohertj2.gitea-act-runner.plist.disabled-2026-08-07`) so it can be
> re-registered with mac-specific labels if a mac-only job ever needs one). For per-job runner
> attribution, `GET /repos/{owner}/{repo}/actions/runs/{id}/jobs`
> exposes `runner_id`/`runner_name` on each job; the `actions/tasks` listing does not.
>
> **Follow-up 2026-08-07 — token hygiene on the host.** `gitea-runner` (id 1) now takes its
> registration token from the same `0600` file mount runner-2 uses instead of an inline plaintext
> value in compose, and `/opt/gitea/docker-compose.yml` plus both `.bak` copies are `0600 root:root`;
> runner-1 was recreated alone and kept its identity (`.runner` byte-identical). Both runners share
> **one instance-scope registration token**, which was world-readable for roughly five months and is
> still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 cannot rotate
> that token from the CLI or the API (both endpoints are get-or-create and return the same value),
> so **the reset is a pending operator action in the admin web UI** ("Reset registration token").
> After the reset, refresh `/opt/gitea/runner_token` with the new value and shred the two
> token-bearing compose backups, which are the last copies of the old one.
Operator steps to relieve the single shared Gitea Actions runner that CI depends on. The Operator steps to relieve the single shared Gitea Actions runner that CI depends on. The
repo-side half of TST-30 (documenting the shared-runner/no-cancel reality and the repo-side half of TST-30 (documenting the shared-runner/no-cancel reality and the
`run-windev-ci.sh` bypass) is already landed in `docs/GatewayTesting.md`; registering the `run-windev-ci.sh` bypass) is already landed in `docs/GatewayTesting.md`; registering the
+18
View File
@@ -431,6 +431,24 @@ Core commands:
- `AuthenticateUser` - `AuthenticateUser`
- `ArchestrAUserToId` - `ArchestrAUserToId`
**Write completion correlation.** MXAccess writes are fire-and-forget
at the toolkit level — the per-item outcome only exists in the later
`OnWriteComplete` callback. For the unary write kinds — `Write`, `Write2`,
`WriteSecured`, `WriteSecured2` — the worker
therefore holds the unary reply for a bounded window
(`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default 1.5 s, `0`
disables) and, when the matching callback arrives, copies its status rows onto
`MxCommandReply.statuses` — the reply then proves the MXAccess commit, not just
command acceptance. `protocol_status`/`hresult` keep describing acceptance
only; a real MXAccess write failure surfaces in `statuses[0]`, and a reply with
empty `statuses` means unconfirmed (the callback missed the window), never
failed. The `OnWriteComplete` event still flows on the event stream unchanged.
Correlation is best-effort per `(server_handle, item_handle)` — the callback
carries no transaction id, so concurrent writes to the same item within the
window can swap rows. The bulk write commands stay
fire-and-forget: waiting per entry would add a device round-trip of latency to
high-rate supervisory write loops.
Bulk variants (single gRPC round-trip carries the full list, the worker Bulk variants (single gRPC round-trip carries the full list, the worker
runs the per-item MXAccess calls sequentially on its STA, and the reply runs the per-item MXAccess calls sequentially on its STA, and the reply
returns one result per requested entry — per-entry failures populate returns one result per requested entry — per-entry failures populate
+3 -2
View File
@@ -40,8 +40,9 @@ gw-specific role.
> it out-of-band (encrypted secrets store reference `${secret:ldap/mxgateway/bind}`, the > it out-of-band (encrypted secrets store reference `${secret:ldap/mxgateway/bind}`, the
> `MxGateway__Ldap__ServiceAccountPassword` env var on deployed hosts, or `dotnet user-secrets` > `MxGateway__Ldap__ServiceAccountPassword` env var on deployed hosts, or `dotnet user-secrets`
> on dev boxes — see `docs/GatewayConfiguration.md`). The credential was historically committed > on dev boxes — see `docs/GatewayConfiguration.md`). The credential was historically committed
> to this repo (and remains recoverable from git history), so **rotation is required**; the > to this repo (and remains recoverable from git history); it **was rotated on 2026-08-07**
> operator runbook is `docs/runbooks/SEC-36-ldap-credential-rotation.md`. > (SEC-36, executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`) and the old
> committed value no longer binds.
## Pre-existing groups (LmxOpcUa role taxonomy) ## Pre-existing groups (LmxOpcUa role taxonomy)
@@ -8012,6 +8012,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
} }
/// <summary>
/// The unary reply's statuses field carries the correlated OnWriteComplete
/// outcome when it arrives within the worker's bounded wait — see
/// MxCommandReply.statuses.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class WriteCommand : pb::IMessage<WriteCommand> public sealed partial class WriteCommand : pb::IMessage<WriteCommand>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
@@ -8330,6 +8335,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
} }
/// <summary>
/// Same statuses correlation as WriteCommand.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class Write2Command : pb::IMessage<Write2Command> public sealed partial class Write2Command : pb::IMessage<Write2Command>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
@@ -8694,6 +8702,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
} }
/// <summary>
/// The unary reply's statuses field carries the correlated OnWriteComplete
/// outcome when it arrives within the worker's bounded wait — see
/// MxCommandReply.statuses.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class WriteSecuredCommand : pb::IMessage<WriteSecuredCommand> public sealed partial class WriteSecuredCommand : pb::IMessage<WriteSecuredCommand>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
@@ -9053,6 +9066,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
} }
/// <summary>
/// The unary reply's statuses field carries the correlated OnWriteComplete
/// outcome when it arrives within the worker's bounded wait — see
/// MxCommandReply.statuses.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class WriteSecured2Command : pb::IMessage<WriteSecured2Command> public sealed partial class WriteSecured2Command : pb::IMessage<WriteSecured2Command>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
@@ -17204,6 +17222,21 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
private static readonly pb::FieldCodec<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> _repeated_statuses_codec private static readonly pb::FieldCodec<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> _repeated_statuses_codec
= pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser); = pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser);
private readonly pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> statuses_ = new pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy>(); private readonly pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> statuses_ = new pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy>();
/// <summary>
/// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
/// WRITE_SECURED2 replies the worker holds the reply for a bounded window
/// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
/// the matching MXAccess OnWriteComplete callback and copies its status rows
/// here, so statuses[0] carries the real MXAccess commit outcome (success OR
/// failure) while protocol_status/hresult still describe command acceptance
/// only. Empty statuses on a write reply means the completion did not arrive
/// within the window — the write is unconfirmed, not failed. Correlation is
/// best-effort per (server_handle, item_handle): MXAccess's callback carries
/// no transaction id, so concurrent writes to the same item within the
/// window can swap rows. The OnWriteComplete event still flows on the event
/// stream unchanged. Bulk write kinds and all non-write kinds leave this
/// field as before.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> Statuses { public pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> Statuses {
@@ -241,6 +241,9 @@ message ActivateCommand {
int32 item_handle = 2; int32 item_handle = 2;
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait see
// MxCommandReply.statuses.
message WriteCommand { message WriteCommand {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -248,6 +251,7 @@ message WriteCommand {
int32 user_id = 4; int32 user_id = 4;
} }
// Same statuses correlation as WriteCommand.
message Write2Command { message Write2Command {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -256,6 +260,9 @@ message Write2Command {
int32 user_id = 5; int32 user_id = 5;
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait see
// MxCommandReply.statuses.
message WriteSecuredCommand { message WriteSecuredCommand {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -266,6 +273,9 @@ message WriteSecuredCommand {
MxValue value = 5; MxValue value = 5;
} }
// The unary reply's statuses field carries the correlated OnWriteComplete
// outcome when it arrives within the worker's bounded wait see
// MxCommandReply.statuses.
message WriteSecured2Command { message WriteSecured2Command {
int32 server_handle = 1; int32 server_handle = 1;
int32 item_handle = 2; int32 item_handle = 2;
@@ -525,6 +535,19 @@ message MxCommandReply {
// transport failures. // transport failures.
optional int32 hresult = 5; optional int32 hresult = 5;
MxValue return_value = 6; MxValue return_value = 6;
// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
// WRITE_SECURED2 replies the worker holds the reply for a bounded window
// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
// the matching MXAccess OnWriteComplete callback and copies its status rows
// here, so statuses[0] carries the real MXAccess commit outcome (success OR
// failure) while protocol_status/hresult still describe command acceptance
// only. Empty statuses on a write reply means the completion did not arrive
// within the window the write is unconfirmed, not failed. Correlation is
// best-effort per (server_handle, item_handle): MXAccess's callback carries
// no transaction id, so concurrent writes to the same item within the
// window can swap rows. The OnWriteComplete event still flows on the event
// stream unchanged. Bulk write kinds and all non-write kinds leave this
// field as before.
repeated MxStatusProxy statuses = 7; repeated MxStatusProxy statuses = 7;
string diagnostic_message = 8; string diagnostic_message = 8;
@@ -14,7 +14,19 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
[Trait("Category", "LiveLdap")] [Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests public sealed class DashboardLdapLiveTests
{ {
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary> /// <summary>
/// The shared dev/test directory issues every human tester the same well-known password, so
/// the fixtures name it once rather than repeating a literal that drifts per test. This is a
/// published dev credential (see <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
/// not a secret — unlike the service-account bind password, which is never in source and must
/// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
/// </summary>
private const string SharedDirectoryPassword = "password";
/// <summary>
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
@@ -23,7 +35,7 @@ public sealed class DashboardLdapLiveTests
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin", "admin",
"admin123", SharedDirectoryPassword,
CancellationToken.None); CancellationToken.None);
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
@@ -38,21 +50,43 @@ public sealed class DashboardLdapLiveTests
&& claim.Value == DashboardRoles.Admin); && claim.Value == DashboardRoles.Admin);
} }
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary> /// <summary>
/// Verifies that <c>gw-viewer</c> — a shared-directory user whose only group is GwReader
/// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied
/// even though its bind succeeds, and that the denial is indistinguishable from the
/// unknown-user denial.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
{ {
DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticator authenticator = CreateAuthenticator();
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"readonly", "gw-viewer",
"readonly123", SharedDirectoryPassword,
CancellationToken.None); CancellationToken.None);
Assert.False(result.Succeeded); Assert.False(result.Succeeded);
Assert.Null(result.Principal); Assert.Null(result.Principal);
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
// This test used to assert the failure message did not echo the credential literal.
// That check cannot survive the move to the shared directory: the real password is the
// word "password", which legitimately occurs in the generic denial text ("The username
// or password is invalid, ..."), so the assertion would fail for the wrong reason. The
// no-leak property is still covered — with a distinctive literal — by
// AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is
// asserted here instead is the property this fixture is actually uniquely able to prove:
// an authorization failure (valid credentials, no mapped role) must be reported with the
// same message as an authentication failure, so the response cannot be used to enumerate
// valid accounts.
DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync(
"no-such-user-9f3c1",
"irrelevant-password",
CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage));
Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage);
} }
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary> /// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
@@ -98,9 +132,11 @@ public sealed class DashboardLdapLiveTests
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{ {
// Exercises the connect-failure path: a closed loopback port produces a // Exercises the connect-failure path: overriding only the port keeps whatever host
// connection error that the shared LdapAuthService must absorb into a Fail // the run targets (localhost by default, the shared GLAuth under the
// result rather than propagating an exception to the dashboard. // MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so
// the connection error the shared LdapAuthService must absorb into a Fail result —
// rather than propagate as an exception to the dashboard — is reproduced either way.
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
{ {
// 1 is a reserved port number that no LDAP server listens on. // 1 is a reserved port number that no LDAP server listens on.
@@ -109,7 +145,7 @@ public sealed class DashboardLdapLiveTests
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin", "admin",
"admin123", SharedDirectoryPassword,
CancellationToken.None); CancellationToken.None);
Assert.False(result.Succeeded); Assert.False(result.Succeeded);
@@ -147,8 +183,10 @@ public sealed class DashboardLdapLiveTests
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the /// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
/// unreachable-server test's timing) at whatever value the operator configured, and /// unreachable-server test's timing) at whatever value the operator configured, and
/// cannot silently drop a field added to the shared type. The gateway's /// cannot silently drop a field added to the shared type. The gateway's
/// <c>appsettings.json</c> seeds the dev directory connection (localhost:3893, /// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
/// plaintext, AllowInsecure). /// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
/// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
/// <c>AddEnvironmentVariables()</c> layer below applies.
/// </summary> /// </summary>
private static LibraryLdapOptions LibraryOptions() private static LibraryLdapOptions LibraryOptions()
{ {
@@ -225,6 +225,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
options.PipeConnectAttemptTimeoutMilliseconds, options.PipeConnectAttemptTimeoutMilliseconds,
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
builder); builder);
if (options.WriteCompletionWaitMilliseconds < 0)
{
builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
}
AddIfNotPositive( AddIfNotPositive(
options.ShutdownTimeoutSeconds, options.ShutdownTimeoutSeconds,
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.", "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
@@ -24,6 +24,15 @@ public sealed class WorkerOptions
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary> /// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000; public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
/// <summary>
/// Bounded wait, in milliseconds, the worker holds a WriteSecured/WriteSecured2
/// reply for the matching MXAccess OnWriteComplete callback so the reply's
/// statuses carry the real commit outcome. 0 disables the wait. Deployments
/// raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A
/// write resilience timeout) must raise those in step.
/// </summary>
public int WriteCompletionWaitMilliseconds { get; init; } = 1500;
/// <summary>The maximum time in seconds for graceful shutdown.</summary> /// <summary>The maximum time in seconds for graceful shutdown.</summary>
public int ShutdownTimeoutSeconds { get; init; } = 10; public int ShutdownTimeoutSeconds { get; init; } = 10;
@@ -21,6 +21,14 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName = public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName =
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS"; "MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
/// <summary>
/// Conveys MxGateway:Worker:WriteCompletionWaitMilliseconds to the worker:
/// the bounded wait for the OnWriteComplete callback on
/// WriteSecured/WriteSecured2 replies. 0 disables the wait.
/// </summary>
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
private readonly IWorkerProcessFactory _processFactory; private readonly IWorkerProcessFactory _processFactory;
private readonly IWorkerStartupProbe _startupProbe; private readonly IWorkerStartupProbe _startupProbe;
private readonly GatewayMetrics _metrics; private readonly GatewayMetrics _metrics;
@@ -175,6 +183,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
startInfo.Environment[WorkerNonceEnvironmentVariableName] = request.Nonce; startInfo.Environment[WorkerNonceEnvironmentVariableName] = request.Nonce;
startInfo.Environment[WorkerPipeConnectAttemptTimeoutEnvironmentVariableName] = startInfo.Environment[WorkerPipeConnectAttemptTimeoutEnvironmentVariableName] =
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture); _workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
commandLine = new WorkerProcessCommandLine(executablePath, arguments); commandLine = new WorkerProcessCommandLine(executablePath, arguments);
@@ -41,6 +41,7 @@ public sealed class GatewayOptionsTests
Assert.Equal(3, options.Worker.StartupProbeRetryAttempts); Assert.Equal(3, options.Worker.StartupProbeRetryAttempts);
Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds); Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds);
Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds); Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds);
Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds);
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds); Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds); Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds);
Assert.Equal(15, options.Worker.HeartbeatGraceSeconds); Assert.Equal(15, options.Worker.HeartbeatGraceSeconds);
@@ -106,6 +107,7 @@ public sealed class GatewayOptionsTests
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")] [InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")] [InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")] [InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
[InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")]
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")] [InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
[InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")] [InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")]
[InlineData("MxGateway:Sessions:DetachGraceSeconds", "-1", "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).")] [InlineData("MxGateway:Sessions:DetachGraceSeconds", "-1", "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).")]
@@ -43,6 +43,10 @@ public sealed class WorkerProcessLauncherTests
"2000", "2000",
processFactory.LastStartInfo.Environment[ processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerPipeConnectAttemptTimeoutEnvironmentVariableName]); WorkerProcessLauncher.WorkerPipeConnectAttemptTimeoutEnvironmentVariableName]);
Assert.Equal(
"1500",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]);
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal); Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal); Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
Assert.False(pipeReservation.DisposeCalled); Assert.False(pipeReservation.DisposeCalled);
@@ -110,6 +110,47 @@ public sealed class MxAccessBaseEventSinkTests
Assert.Same(cache, sink.ValueCache); Assert.Same(cache, sink.ValueCache);
} }
/// <summary>
/// Verifies that an OnWriteComplete COM callback records the completion into
/// the per-session write-completion cache for the executor's bounded reply
/// wait AND still enqueues the event unchanged for the outbound stream —
/// correlation observes the event, it never consumes it. The cache update
/// fires only after the event has cleared the queue (post-publish rule).
/// </summary>
[Fact]
public void OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent()
{
MxAccessEventQueue queue = new();
MxAccessWriteCompletionCache completionCache = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), new MxAccessValueCache(), completionCache);
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
sink.OnWriteComplete(hLMXServerHandle: 7, phItemHandle: 21, ref statuses);
Assert.Equal(1, queue.Count);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family);
Assert.Equal(7, mxEvent.ServerHandle);
Assert.Equal(21, mxEvent.ItemHandle);
Assert.Equal(1UL, completionCache.CurrentVersion(7, 21));
}
/// <summary>
/// Verifies that the sink-bound write-completion cache is exposed for sharing
/// with the owning <see cref="MxAccessSession"/> so the sink's recordings and
/// the write executor's waits see the same instance.
/// </summary>
[Fact]
public void WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction()
{
MxAccessEventQueue queue = new();
MxAccessWriteCompletionCache completionCache = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), new MxAccessValueCache(), completionCache);
Assert.Same(completionCache, sink.WriteCompletionCache);
}
/// <summary> /// <summary>
/// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences. /// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences.
/// </summary> /// </summary>
@@ -851,6 +851,9 @@ public sealed class MxAccessCommandExecutorTests
FakeMxAccessComObjectFactory factory = new(fakeComObject); FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime(); using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
// No completion source in this test — disable the bounded reply wait
// so the forwarding assertions don't pay the default 1.5 s timeout.
session.WriteCompletionTimeout = TimeSpan.Zero;
await session.StartAsync(workerProcessId: 1234); await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand( MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
@@ -874,6 +877,8 @@ public sealed class MxAccessCommandExecutorTests
FakeMxAccessComObjectFactory factory = new(fakeComObject); FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime(); using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
// Same rationale as the Write forwarding test above.
session.WriteCompletionTimeout = TimeSpan.Zero;
await session.StartAsync(workerProcessId: 1234); await session.StartAsync(workerProcessId: 1234);
DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
@@ -897,6 +902,9 @@ public sealed class MxAccessCommandExecutorTests
FakeMxAccessComObjectFactory factory = new(fakeComObject); FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime(); using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
// No completion source in this test — disable the bounded reply wait
// so the forwarding assertions don't pay the default 1.5 s timeout.
session.WriteCompletionTimeout = TimeSpan.Zero;
await session.StartAsync(workerProcessId: 1234); await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
@@ -919,6 +927,8 @@ public sealed class MxAccessCommandExecutorTests
FakeMxAccessComObjectFactory factory = new(fakeComObject); FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime(); using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
// Same rationale as the WriteSecured forwarding test above.
session.WriteCompletionTimeout = TimeSpan.Zero;
await session.StartAsync(workerProcessId: 1234); await session.StartAsync(workerProcessId: 1234);
DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc); DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);
@@ -934,6 +944,277 @@ public sealed class MxAccessCommandExecutorTests
Assert.Equal(44, fakeComObject.WriteVerifierUserId); Assert.Equal(44, fakeComObject.WriteVerifierUserId);
} }
/// <summary>
/// Verifies the fast-completion ordering edge: a completion recorded while
/// the WriteSecured COM call is still on the stack (MXAccess committing
/// synchronously) is newer than the pre-call baseline and lands on the
/// reply — the wait never misses a callback that beat it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
fakeComObject.OnWriteCallback = () =>
sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
// Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
// from the test runner's environment.
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
"write-secured-fast", serverHandle: 82, itemHandle: 820, value: 1, currentUserId: 11, verifierUserId: 22));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(reply.HasHresult);
Assert.Equal(0, reply.Hresult);
MxStatusProxy row = Assert.Single(reply.Statuses);
Assert.Equal(4321, row.Detail);
Assert.Equal(MxStatusCategory.Ok, row.Category);
}
/// <summary>
/// Verifies the pump-wait path: the completion arrives after the COM call
/// returned, while the executor is pump-waiting, and still lands on the
/// reply.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 83);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
// Deterministic ordering: the executor captures its version baseline
// BEFORE the COM call, so once the fake's WriteSecured has run the
// baseline is committed and a Record from the test thread is
// guaranteed to be "newer" — no fixed sleep racing the STA thread.
using System.Threading.ManualResetEventSlim comCallReached = new(initialState: false);
fakeComObject.OnWriteCallback = () => comCallReached.Set();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
await session.StartAsync(workerProcessId: 1234);
Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteSecuredCommand(
"write-secured-waiting", serverHandle: 83, itemHandle: 830, value: 1, currentUserId: 11, verifierUserId: 22));
Assert.True(comCallReached.Wait(TimeSpan.FromSeconds(5)));
sink.WriteCompletionCache.Record(83, 830, CreateCompletionRows(detail: 99));
MxCommandReply reply = await pending;
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(99, Assert.Single(reply.Statuses).Detail);
}
/// <summary>
/// Verifies the timeout fallback: no completion within the bounded wait
/// returns today's reply shape — protocol OK with EMPTY statuses (the
/// consumer's honest-unconfirmed path), never a synthesized failure row.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 84);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
"write-secured-timeout", serverHandle: 84, itemHandle: 840, value: 1, currentUserId: 11, verifierUserId: 22));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(reply.HasHresult);
Assert.Equal(0, reply.Hresult);
Assert.Empty(reply.Statuses);
}
/// <summary>
/// Verifies the version-baseline rule end to end: a completion recorded
/// BEFORE the write was dispatched is stale and must not be misattributed
/// to this write — the reply times out empty instead.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 85);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
sink.WriteCompletionCache.Record(85, 850, CreateCompletionRows(detail: 1111));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
"write-secured-stale", serverHandle: 85, itemHandle: 850, value: 1, currentUserId: 11, verifierUserId: 22));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Empty(reply.Statuses);
}
/// <summary>
/// Verifies that WriteSecured2 correlates the same way as WriteSecured
/// (fast-completion edge).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 86);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
fakeComObject.OnWriteCallback = () =>
sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
// Hermetic: same rationale as the WriteSecured fast-completion test.
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command(
"write-secured2-fast", serverHandle: 86, itemHandle: 860, value: 1,
timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), currentUserId: 33, verifierUserId: 44));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(2222, Assert.Single(reply.Statuses).Detail);
}
/// <summary>
/// Verifies plain Write correlates the same way as WriteSecured
/// (fast-completion edge): OtOpcUa's dominant FreeAccess path goes out
/// as MX_COMMAND_KIND_WRITE, so its reply must carry the OnWriteComplete
/// rows too.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_Write_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 87);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
fakeComObject.OnWriteCallback = () =>
sink.WriteCompletionCache.Record(87, 870, CreateCompletionRows(detail: 5555));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
// Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
// from the test runner's environment.
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
"plain-write-fast", serverHandle: 87, itemHandle: 870, value: 1, userId: 5));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(reply.HasHresult);
Assert.Equal(0, reply.Hresult);
MxStatusProxy row = Assert.Single(reply.Statuses);
Assert.Equal(5555, row.Detail);
Assert.Equal(MxStatusCategory.Ok, row.Category);
}
/// <summary>
/// Verifies the plain-Write timeout fallback mirrors the secured one:
/// no completion within the bounded wait returns protocol OK with EMPTY
/// statuses — unconfirmed, never a synthesized failure row.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_Write_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 88);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
"plain-write-timeout", serverHandle: 88, itemHandle: 880, value: 1, userId: 5));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(reply.HasHresult);
Assert.Equal(0, reply.Hresult);
Assert.Empty(reply.Statuses);
}
/// <summary>
/// Verifies Write2 correlates the same way as Write (fast-completion
/// edge).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_Write2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 89);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
fakeComObject.OnWriteCallback = () =>
sink.WriteCompletionCache.Record(89, 890, CreateCompletionRows(detail: 6666));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command(
"plain-write2-fast", serverHandle: 89, itemHandle: 890, value: 1,
timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), userId: 6));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(6666, Assert.Single(reply.Statuses).Detail);
}
/// <summary>
/// Verifies WriteBulk stays fire-and-forget: even with a huge completion
/// timeout configured and no completion source, the reply returns
/// immediately with per-entry results — bulk writes never enter the
/// bounded wait (latency for high-rate loops).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DispatchAsync_WriteBulk_DoesNotWaitForCompletion()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 90);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
CompletionCacheEventSink sink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, sink);
session.WriteCompletionTimeout = TimeSpan.FromSeconds(30);
await session.StartAsync(workerProcessId: 1234);
Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteBulkCommand(
"bulk-write-no-wait", serverHandle: 90, entries: new[] { (itemHandle: 900, value: 1, userId: 5) }));
Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Same(pending, completed);
MxCommandReply reply = await pending;
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Empty(reply.Statuses);
}
private static Google.Protobuf.Collections.RepeatedField<MxStatusProxy> CreateCompletionRows(int detail)
{
return new Google.Protobuf.Collections.RepeatedField<MxStatusProxy>
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
Detail = detail,
},
};
}
/// <summary>Verifies that Write without a payload returns an invalid request error.</summary> /// <summary>Verifies that Write without a payload returns an invalid request error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -1775,6 +2056,28 @@ public sealed class MxAccessCommandExecutorTests
TimeSpan.FromMilliseconds(25)); TimeSpan.FromMilliseconds(25));
} }
/// <summary>
/// Test sink that owns a real write-completion cache without touching the
/// MXAccess COM RCW (Attach is a no-op). Implements the provider seam so
/// <see cref="MxAccessSession.Create"/> shares this cache with the write
/// executor, letting tests record completions the executor's bounded
/// wait then observes.
/// </summary>
private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider
{
public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache();
public void Attach(
object mxAccessComObject,
string sessionId)
{
}
public void Detach()
{
}
}
private sealed class FakeMxAccessComObject : IMxAccessServer private sealed class FakeMxAccessComObject : IMxAccessServer
{ {
private readonly int registerHandle; private readonly int registerHandle;
@@ -1790,6 +2093,15 @@ public sealed class MxAccessCommandExecutorTests
private readonly IReadOnlyDictionary<int, Exception> writeExceptionByItemHandle; private readonly IReadOnlyDictionary<int, Exception> writeExceptionByItemHandle;
private readonly List<string> operationNames = new(); private readonly List<string> operationNames = new();
/// <summary>
/// Invoked at the end of a successful Write/Write2/WriteSecured/
/// WriteSecured2 — stands in for MXAccess committing synchronously
/// and delivering OnWriteComplete while the COM call is still on
/// the stack, so tests can exercise the fast-completion ordering
/// edge on every correlated write kind.
/// </summary>
public Action? OnWriteCallback { get; set; }
/// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary> /// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
/// <param name="registerHandle">Return value for Register method.</param> /// <param name="registerHandle">Return value for Register method.</param>
/// <param name="addItemHandle">Return value for AddItem method.</param> /// <param name="addItemHandle">Return value for AddItem method.</param>
@@ -2064,6 +2376,7 @@ public sealed class MxAccessCommandExecutorTests
WriteUserId = userId; WriteUserId = userId;
WriteThreadId = Environment.CurrentManagedThreadId; WriteThreadId = Environment.CurrentManagedThreadId;
ThrowIfWriteFailureConfigured(itemHandle); ThrowIfWriteFailureConfigured(itemHandle);
OnWriteCallback?.Invoke();
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -2082,6 +2395,7 @@ public sealed class MxAccessCommandExecutorTests
WriteUserId = userId; WriteUserId = userId;
WriteThreadId = Environment.CurrentManagedThreadId; WriteThreadId = Environment.CurrentManagedThreadId;
ThrowIfWriteFailureConfigured(itemHandle); ThrowIfWriteFailureConfigured(itemHandle);
OnWriteCallback?.Invoke();
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -2100,6 +2414,7 @@ public sealed class MxAccessCommandExecutorTests
WriteValue = value; WriteValue = value;
WriteThreadId = Environment.CurrentManagedThreadId; WriteThreadId = Environment.CurrentManagedThreadId;
ThrowIfWriteFailureConfigured(itemHandle); ThrowIfWriteFailureConfigured(itemHandle);
OnWriteCallback?.Invoke();
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -2120,6 +2435,7 @@ public sealed class MxAccessCommandExecutorTests
WriteTimestamp = timestamp; WriteTimestamp = timestamp;
WriteThreadId = Environment.CurrentManagedThreadId; WriteThreadId = Environment.CurrentManagedThreadId;
ThrowIfWriteFailureConfigured(itemHandle); ThrowIfWriteFailureConfigured(itemHandle);
OnWriteCallback?.Invoke();
} }
private void ThrowIfWriteFailureConfigured(int itemHandle) private void ThrowIfWriteFailureConfigured(int itemHandle)
@@ -15,6 +15,48 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary> /// </summary>
public sealed class MxAccessStaSessionTests public sealed class MxAccessStaSessionTests
{ {
/// <summary>
/// Verifies the launcher-env-var parse branches of
/// <see cref="MxAccessStaSession.ResolveWriteCompletionTimeout"/>:
/// a valid non-negative value is honored (0 = disabled), while a
/// missing, malformed, or negative value falls back to the executor
/// default. Env mutation is restored in a finally so parallel tests
/// never observe the temporary value.
/// </summary>
/// <param name="rawValue">Raw env-var value, or null for unset.</param>
/// <param name="expectedMilliseconds">Expected resolved wait, or null for the executor default.</param>
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("junk", null)]
[InlineData("-5", null)]
[InlineData("0", 0)]
[InlineData("250", 250)]
public void ResolveWriteCompletionTimeout_ParsesEnvironmentValue(string? rawValue, int? expectedMilliseconds)
{
string? original = Environment.GetEnvironmentVariable(
MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName);
try
{
Environment.SetEnvironmentVariable(
MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName,
rawValue);
TimeSpan resolved = MxAccessStaSession.ResolveWriteCompletionTimeout();
TimeSpan expected = expectedMilliseconds is null
? MxAccessCommandExecutor.DefaultWriteCompletionTimeout
: TimeSpan.FromMilliseconds(expectedMilliseconds.Value);
Assert.Equal(expected, resolved);
}
finally
{
Environment.SetEnvironmentVariable(
MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName,
original);
}
}
/// <summary> /// <summary>
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
/// </summary> /// </summary>
@@ -0,0 +1,145 @@
using System;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="MxAccessWriteCompletionCache"/>. The cache is
/// consumed by the write command executor's bounded pump-wait so a
/// WriteSecured/WriteSecured2 reply can carry the correlated
/// OnWriteComplete outcome; its version-baseline contract is exercised in
/// isolation here before the STA / COM plumbing gets layered on top.
/// </summary>
public sealed class MxAccessWriteCompletionCacheTests
{
/// <summary>Verifies that Record bumps the version per key and keys stay isolated.</summary>
[Fact]
public void Record_IncrementsVersionPerKey()
{
MxAccessWriteCompletionCache cache = new();
Assert.Equal(0UL, cache.CurrentVersion(7, 21));
cache.Record(7, 21, BuildStatuses(detail: 100));
Assert.Equal(1UL, cache.CurrentVersion(7, 21));
cache.Record(7, 21, BuildStatuses(detail: 200));
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
cache.Record(7, 22, BuildStatuses(detail: 300));
Assert.Equal(1UL, cache.CurrentVersion(7, 22));
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
}
/// <summary>Verifies that a completion newer than the baseline is returned with its status rows.</summary>
[Fact]
public void TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses()
{
MxAccessWriteCompletionCache cache = new();
cache.Record(7, 21, BuildStatuses(detail: 4321));
bool found = cache.TryWaitForCompletion(
7,
21,
sinceVersion: 0UL,
deadlineUtc: DateTime.UtcNow.AddSeconds(5),
pumpStep: static () => { },
out RepeatedField<MxStatusProxy> statuses);
Assert.True(found);
MxStatusProxy row = Assert.Single(statuses);
Assert.Equal(4321, row.Detail);
Assert.Equal(MxStatusCategory.Ok, row.Category);
}
/// <summary>
/// Verifies that a completion recorded before the baseline was captured is
/// never misattributed to the waiting write: only a strictly newer version
/// satisfies the wait, so a stale row times the wait out.
/// </summary>
[Fact]
public void TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut()
{
MxAccessWriteCompletionCache cache = new();
cache.Record(7, 21, BuildStatuses(detail: 4321));
ulong baseline = cache.CurrentVersion(7, 21);
bool found = cache.TryWaitForCompletion(
7,
21,
sinceVersion: baseline,
deadlineUtc: DateTime.UtcNow.AddMilliseconds(50),
pumpStep: static () => { },
out RepeatedField<MxStatusProxy> statuses);
Assert.False(found);
Assert.Empty(statuses);
}
/// <summary>
/// Verifies the pump loop is what lets a completion land: the completion is
/// recorded from inside a later pump step (standing in for the STA
/// dispatching the OnWriteComplete message) and the wait then succeeds.
/// </summary>
[Fact]
public void TryWaitForCompletion_InvokesPumpStepEachIteration()
{
MxAccessWriteCompletionCache cache = new();
int pumpCalls = 0;
bool found = cache.TryWaitForCompletion(
7,
21,
sinceVersion: 0UL,
deadlineUtc: DateTime.UtcNow.AddSeconds(5),
pumpStep: () =>
{
pumpCalls++;
if (pumpCalls == 2)
{
cache.Record(7, 21, BuildStatuses(detail: 55));
}
},
out RepeatedField<MxStatusProxy> statuses);
Assert.True(found);
Assert.True(pumpCalls >= 2);
Assert.Equal(55, Assert.Single(statuses).Detail);
}
/// <summary>Verifies that Record stores an independent clone of the caller's rows.</summary>
[Fact]
public void Record_ClonesStatuses()
{
MxAccessWriteCompletionCache cache = new();
RepeatedField<MxStatusProxy> callerRows = BuildStatuses(detail: 77);
cache.Record(7, 21, callerRows);
callerRows[0].Detail = 999;
callerRows.Add(new MxStatusProxy());
Assert.True(cache.TryWaitForCompletion(
7,
21,
sinceVersion: 0UL,
deadlineUtc: DateTime.UtcNow.AddSeconds(5),
pumpStep: static () => { },
out RepeatedField<MxStatusProxy> statuses));
Assert.Equal(77, Assert.Single(statuses).Detail);
}
private static RepeatedField<MxStatusProxy> BuildStatuses(int detail)
{
return new RepeatedField<MxStatusProxy>
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
Detail = detail,
},
};
}
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Exposes the per-session <see cref="MxAccessWriteCompletionCache"/> an
/// event sink populates from OnWriteComplete callbacks, so
/// <see cref="MxAccessSession.Create"/> can share one instance between the
/// sink (writer) and the write command executor (reader). Implemented by
/// <see cref="MxAccessBaseEventSink"/> and by test sinks that cannot
/// attach to a live MXAccess COM object.
/// </summary>
public interface IWriteCompletionCacheProvider
{
/// <summary>The completion cache bound to this sink.</summary>
MxAccessWriteCompletionCache WriteCompletionCache { get; }
}
@@ -5,11 +5,12 @@ using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Sink for MXAccess COM events that converts them to protobuf format.</summary> /// <summary>Sink for MXAccess COM events that converts them to protobuf format.</summary>
public sealed class MxAccessBaseEventSink : IMxAccessEventSink public sealed class MxAccessBaseEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider
{ {
private readonly MxAccessEventMapper eventMapper; private readonly MxAccessEventMapper eventMapper;
private readonly MxAccessEventQueue eventQueue; private readonly MxAccessEventQueue eventQueue;
private readonly MxAccessValueCache valueCache; private readonly MxAccessValueCache valueCache;
private readonly MxAccessWriteCompletionCache writeCompletionCache;
private LMXProxyServerClass? server; private LMXProxyServerClass? server;
private string sessionId = string.Empty; private string sessionId = string.Empty;
@@ -50,10 +51,32 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
MxAccessEventQueue eventQueue, MxAccessEventQueue eventQueue,
MxAccessEventMapper eventMapper, MxAccessEventMapper eventMapper,
MxAccessValueCache valueCache) MxAccessValueCache valueCache)
: this(eventQueue, eventMapper, valueCache, new MxAccessWriteCompletionCache())
{
}
/// <summary>
/// Initializes a new instance of the MxAccessBaseEventSink class with
/// provided queue, mapper, value cache, and a shared write-completion
/// cache. The completion cache is populated from every successful
/// <c>OnWriteComplete</c> dispatch so the worker's write executor can
/// correlate a WriteSecured/WriteSecured2 reply with the MXAccess
/// completion outcome.
/// </summary>
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
/// <param name="valueCache">Per-session last-value cache shared with the MxAccessSession.</param>
/// <param name="writeCompletionCache">Per-session OnWriteComplete cache shared with the MxAccessSession.</param>
public MxAccessBaseEventSink(
MxAccessEventQueue eventQueue,
MxAccessEventMapper eventMapper,
MxAccessValueCache valueCache,
MxAccessWriteCompletionCache writeCompletionCache)
{ {
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper)); this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache)); this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache));
} }
/// <summary> /// <summary>
@@ -62,6 +85,14 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
/// </summary> /// </summary>
public MxAccessValueCache ValueCache => valueCache; public MxAccessValueCache ValueCache => valueCache;
/// <summary>
/// The OnWriteComplete completion cache populated by this sink. Exposed
/// via <see cref="IWriteCompletionCacheProvider"/> so the
/// MxAccessSession can share the same instance with the write command
/// executor's bounded completion wait.
/// </summary>
public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;
/// <inheritdoc /> /// <inheritdoc />
public void Attach( public void Attach(
object mxAccessComObject, object mxAccessComObject,
@@ -143,11 +174,18 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
ref MXSTATUS_PROXY[] pVars) ref MXSTATUS_PROXY[] pVars)
{ {
MXSTATUS_PROXY[] statuses = pVars; MXSTATUS_PROXY[] statuses = pVars;
EnqueueEvent(() => eventMapper.CreateOnWriteComplete( // Record the completion for the write executor's bounded reply wait
// only after the event has cleared the queue (same post-publish rule
// as the OnDataChange value cache) — an overflow faults the session,
// so a dropped event never leaves a "fresher" completion behind than
// what shipped to the gateway.
EnqueueEvent(
() => eventMapper.CreateOnWriteComplete(
sessionId, sessionId,
hLMXServerHandle, hLMXServerHandle,
phItemHandle, phItemHandle,
statuses)); statuses),
mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses));
} }
/// <summary> /// <summary>
@@ -14,11 +14,22 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary> /// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000); internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
/// <summary>
/// Default bounded wait for the OnWriteComplete callback after a
/// WriteSecured/WriteSecured2 COM call. 1.5 s keeps the unary reply
/// inside the OtOpcUa driver's 2 s Tier A write-resilience budget (a
/// longer gateway wait must raise that consumer timeout in step) while
/// covering the common fast-commit case; on expiry the reply returns
/// with empty statuses — unconfirmed, not failed.
/// </summary>
internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500);
private readonly MxAccessSession session; private readonly MxAccessSession session;
private readonly VariantConverter variantConverter; private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter; private readonly MxStatusProxyConverter statusProxyConverter;
private readonly IAlarmCommandHandler? alarmCommandHandler; private readonly IAlarmCommandHandler? alarmCommandHandler;
private readonly Action pumpStep; private readonly Action pumpStep;
private readonly TimeSpan writeCompletionTimeout;
/// <summary> /// <summary>
/// Initializes a command executor with an MXAccess session. /// Initializes a command executor with an MXAccess session.
@@ -71,17 +82,26 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param> /// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
/// <param name="alarmCommandHandler">Optional handler for alarm-side commands.</param> /// <param name="alarmCommandHandler">Optional handler for alarm-side commands.</param>
/// <param name="pumpStep">Action to pump Windows messages, or null for tests.</param> /// <param name="pumpStep">Action to pump Windows messages, or null for tests.</param>
/// <param name="writeCompletionTimeout">
/// Bounded wait for the OnWriteComplete callback after a
/// WriteSecured/WriteSecured2 COM call, or null for
/// <see cref="DefaultWriteCompletionTimeout"/>. Zero (or negative)
/// disables the wait entirely — replies keep the pure fire-and-forget
/// shape.
/// </param>
public MxAccessCommandExecutor( public MxAccessCommandExecutor(
MxAccessSession session, MxAccessSession session,
VariantConverter variantConverter, VariantConverter variantConverter,
IAlarmCommandHandler? alarmCommandHandler, IAlarmCommandHandler? alarmCommandHandler,
Action? pumpStep) Action? pumpStep,
TimeSpan? writeCompletionTimeout = null)
{ {
this.session = session ?? throw new ArgumentNullException(nameof(session)); this.session = session ?? throw new ArgumentNullException(nameof(session));
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter)); this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = new MxStatusProxyConverter(); this.statusProxyConverter = new MxStatusProxyConverter();
this.alarmCommandHandler = alarmCommandHandler; this.alarmCommandHandler = alarmCommandHandler;
this.pumpStep = pumpStep ?? (static () => { }); this.pumpStep = pumpStep ?? (static () => { });
this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -407,13 +427,28 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "Write command value is required."); return CreateInvalidRequestReply(command, "Write command value is required.");
} }
// Same pre-call baseline rule as ExecuteWriteSecured: plain Write is
// also fire-and-forget in MXAccess, and its commit outcome only exists
// in the later OnWriteComplete callback.
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
ulong completionBaseline = completionCache.CurrentVersion(
writeCommand.ServerHandle,
writeCommand.ItemHandle);
session.Write( session.Write(
writeCommand.ServerHandle, writeCommand.ServerHandle,
writeCommand.ItemHandle, writeCommand.ItemHandle,
variantConverter.ConvertToComValue(writeCommand.Value), variantConverter.ConvertToComValue(writeCommand.Value),
writeCommand.UserId); writeCommand.UserId);
return CreateOkReply(command); MxCommandReply reply = CreateOkReply(command);
AwaitWriteCompletion(
reply,
completionCache,
writeCommand.ServerHandle,
writeCommand.ItemHandle,
completionBaseline);
return reply;
} }
private MxCommandReply ExecuteWrite2(StaCommand command) private MxCommandReply ExecuteWrite2(StaCommand command)
@@ -434,6 +469,12 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "Write2 command timestamp value is required."); return CreateInvalidRequestReply(command, "Write2 command timestamp value is required.");
} }
// Same pre-call baseline rule as ExecuteWriteSecured.
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
ulong completionBaseline = completionCache.CurrentVersion(
write2Command.ServerHandle,
write2Command.ItemHandle);
session.Write2( session.Write2(
write2Command.ServerHandle, write2Command.ServerHandle,
write2Command.ItemHandle, write2Command.ItemHandle,
@@ -441,7 +482,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
variantConverter.ConvertToComValue(write2Command.TimestampValue), variantConverter.ConvertToComValue(write2Command.TimestampValue),
write2Command.UserId); write2Command.UserId);
return CreateOkReply(command); MxCommandReply reply = CreateOkReply(command);
AwaitWriteCompletion(
reply,
completionCache,
write2Command.ServerHandle,
write2Command.ItemHandle,
completionBaseline);
return reply;
} }
private MxCommandReply ExecuteWriteSecured(StaCommand command) private MxCommandReply ExecuteWriteSecured(StaCommand command)
@@ -457,6 +505,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "WriteSecured command value is required."); return CreateInvalidRequestReply(command, "WriteSecured command value is required.");
} }
// Baseline BEFORE the COM call: a completion that dispatches during or
// immediately after WriteSecured bumps the version past this snapshot,
// so a fast commit still correlates (no missed-callback window).
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
ulong completionBaseline = completionCache.CurrentVersion(
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle);
session.WriteSecured( session.WriteSecured(
writeSecuredCommand.ServerHandle, writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle, writeSecuredCommand.ItemHandle,
@@ -464,7 +520,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
writeSecuredCommand.VerifierUserId, writeSecuredCommand.VerifierUserId,
variantConverter.ConvertToComValue(writeSecuredCommand.Value)); variantConverter.ConvertToComValue(writeSecuredCommand.Value));
return CreateOkReply(command); MxCommandReply reply = CreateOkReply(command);
AwaitWriteCompletion(
reply,
completionCache,
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle,
completionBaseline);
return reply;
} }
private MxCommandReply ExecuteWriteSecured2(StaCommand command) private MxCommandReply ExecuteWriteSecured2(StaCommand command)
@@ -485,6 +548,12 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required."); return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required.");
} }
// Same pre-call baseline rule as ExecuteWriteSecured.
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
ulong completionBaseline = completionCache.CurrentVersion(
writeSecured2Command.ServerHandle,
writeSecured2Command.ItemHandle);
session.WriteSecured2( session.WriteSecured2(
writeSecured2Command.ServerHandle, writeSecured2Command.ServerHandle,
writeSecured2Command.ItemHandle, writeSecured2Command.ItemHandle,
@@ -493,7 +562,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
variantConverter.ConvertToComValue(writeSecured2Command.Value), variantConverter.ConvertToComValue(writeSecured2Command.Value),
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue)); variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue));
return CreateOkReply(command); MxCommandReply reply = CreateOkReply(command);
AwaitWriteCompletion(
reply,
completionCache,
writeSecured2Command.ServerHandle,
writeSecured2Command.ItemHandle,
completionBaseline);
return reply;
} }
private MxCommandReply ExecuteAddItemBulk(StaCommand command) private MxCommandReply ExecuteAddItemBulk(StaCommand command)
@@ -897,6 +973,39 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
} }
} }
/// <summary>
/// Bounded pump-wait for the OnWriteComplete row matching a
/// WriteSecured/WriteSecured2 call, copied onto the reply when it
/// arrives in time. The executor holds the STA thread but pumps
/// Windows messages each poll (ReadBulk precedent) so the COM callback
/// can dispatch re-entrantly; on expiry the reply keeps its empty
/// statuses — the consumer's unconfirmed path, never a synthesized
/// failure. Protocol status/hresult stay acceptance-only either way.
/// </summary>
private void AwaitWriteCompletion(
MxCommandReply reply,
MxAccessWriteCompletionCache completionCache,
int serverHandle,
int itemHandle,
ulong completionBaseline)
{
if (writeCompletionTimeout <= TimeSpan.Zero)
{
return;
}
if (completionCache.TryWaitForCompletion(
serverHandle,
itemHandle,
completionBaseline,
DateTime.UtcNow + writeCompletionTimeout,
pumpStep,
out Google.Protobuf.Collections.RepeatedField<MxStatusProxy> statuses))
{
reply.Statuses.Add(statuses);
}
}
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception) private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
{ {
return new MxCommandReply return new MxCommandReply
@@ -13,6 +13,7 @@ public sealed class MxAccessSession : IDisposable
private readonly IMxAccessEventSink eventSink; private readonly IMxAccessEventSink eventSink;
private readonly MxAccessHandleRegistry handleRegistry; private readonly MxAccessHandleRegistry handleRegistry;
private readonly MxAccessValueCache valueCache; private readonly MxAccessValueCache valueCache;
private readonly MxAccessWriteCompletionCache writeCompletionCache;
private bool disposed; private bool disposed;
private MxAccessSession( private MxAccessSession(
@@ -21,6 +22,7 @@ public sealed class MxAccessSession : IDisposable
IMxAccessEventSink eventSink, IMxAccessEventSink eventSink,
MxAccessHandleRegistry handleRegistry, MxAccessHandleRegistry handleRegistry,
MxAccessValueCache valueCache, MxAccessValueCache valueCache,
MxAccessWriteCompletionCache writeCompletionCache,
int creationThreadId) int creationThreadId)
{ {
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject)); this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
@@ -28,6 +30,7 @@ public sealed class MxAccessSession : IDisposable
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink)); this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry)); this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache)); this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache));
CreationThreadId = creationThreadId; CreationThreadId = creationThreadId;
} }
@@ -45,6 +48,14 @@ public sealed class MxAccessSession : IDisposable
/// </summary> /// </summary>
public MxAccessValueCache ValueCache => valueCache; public MxAccessValueCache ValueCache => valueCache;
/// <summary>
/// Per-session OnWriteComplete completion cache populated by the event
/// sink. The write command executor consults it after a
/// WriteSecured/WriteSecured2 COM call so the unary reply can carry
/// the correlated completion outcome.
/// </summary>
public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;
/// <summary>Creates a WorkerReady message with session metadata.</summary> /// <summary>Creates a WorkerReady message with session metadata.</summary>
/// <param name="workerProcessId">Process ID of the worker.</param> /// <param name="workerProcessId">Process ID of the worker.</param>
/// <returns>The populated <see cref="WorkerReady"/> message.</returns> /// <returns>The populated <see cref="WorkerReady"/> message.</returns>
@@ -105,6 +116,9 @@ public sealed class MxAccessSession : IDisposable
eventSink, eventSink,
handleRegistry ?? new MxAccessHandleRegistry(), handleRegistry ?? new MxAccessHandleRegistry(),
valueCache ?? new MxAccessValueCache(), valueCache ?? new MxAccessValueCache(),
eventSink is IWriteCompletionCacheProvider provider
? provider.WriteCompletionCache
: new MxAccessWriteCompletionCache(),
creationThreadId ?? Environment.CurrentManagedThreadId); creationThreadId ?? Environment.CurrentManagedThreadId);
} }
@@ -149,12 +163,22 @@ public sealed class MxAccessSession : IDisposable
? baseSink.ValueCache ? baseSink.ValueCache
: new MxAccessValueCache(); : new MxAccessValueCache();
// Share the sink's completion cache the same way (the production
// sink and completion-aware test sinks implement the provider
// seam); fall back to a fresh cache for other fakes — the write
// executor then simply never observes a completion and replies
// unconfirmed.
MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider
? provider.WriteCompletionCache
: new MxAccessWriteCompletionCache();
return new MxAccessSession( return new MxAccessSession(
mxAccessComObject, mxAccessComObject,
new MxAccessComServer(mxAccessComObject), new MxAccessComServer(mxAccessComObject),
eventSink, eventSink,
new MxAccessHandleRegistry(), new MxAccessHandleRegistry(),
valueCache, valueCache,
writeCompletionCache,
Environment.CurrentManagedThreadId); Environment.CurrentManagedThreadId);
} }
catch (Exception exception) catch (Exception exception)
@@ -11,6 +11,14 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessStaSession : IWorkerRuntimeSession public sealed class MxAccessStaSession : IWorkerRuntimeSession
{ {
/// <summary>
/// Environment variable the gateway's WorkerProcessLauncher sets from
/// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the
/// write-completion wait (pure fire-and-forget replies).
/// </summary>
internal const string WriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
private readonly IMxAccessComObjectFactory factory; private readonly IMxAccessComObjectFactory factory;
@@ -157,6 +165,32 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
/// </summary> /// </summary>
public MxAccessEventQueue EventQueue => eventQueue; public MxAccessEventQueue EventQueue => eventQueue;
/// <summary>
/// Bounded WriteSecured/WriteSecured2 completion wait handed to the
/// command executor at <see cref="StartAsync(string, int, CancellationToken)"/>.
/// Internal-settable as a test seam so Worker.Tests can shorten it
/// without env-var plumbing.
/// </summary>
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
/// <summary>
/// Resolves the write-completion wait from the launcher-provided
/// environment variable; a missing or invalid value falls back to
/// <see cref="MxAccessCommandExecutor.DefaultWriteCompletionTimeout"/>.
/// </summary>
internal static TimeSpan ResolveWriteCompletionTimeout()
{
string? value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName);
return int.TryParse(
value,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out int milliseconds)
&& milliseconds >= 0
? TimeSpan.FromMilliseconds(milliseconds)
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
}
/// <summary> /// <summary>
/// Starts the MXAccess COM session asynchronously. /// Starts the MXAccess COM session asynchronously.
/// </summary> /// </summary>
@@ -208,12 +242,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
session, session,
new VariantConverter(), new VariantConverter(),
alarmCommandHandler, alarmCommandHandler,
// ReadBulk needs to pump Windows messages while it waits // ReadBulk and the write-completion wait need to pump
// for the first OnDataChange callback so the inbound COM // Windows messages while they wait for the inbound COM
// event can dispatch on this same STA thread. The pump // callback (OnDataChange / OnWriteComplete) so it can
// step closes over staRuntime so it always pumps the // dispatch on this same STA thread. The pump step
// pump tied to the apartment that owns this session. // closes over staRuntime so it always pumps the pump
pumpStep: () => staRuntime.PumpPendingMessages())); // tied to the apartment that owns this session.
pumpStep: () => staRuntime.PumpPendingMessages(),
writeCompletionTimeout: WriteCompletionTimeout));
return session.CreateWorkerReady(workerProcessId); return session.CreateWorkerReady(workerProcessId);
}, },
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session cache of the most recent <c>OnWriteComplete</c> status rows
/// for each (server handle, item handle) pair. Written by the MXAccess
/// event sink as completion callbacks arrive; read by the write command
/// executor so a WriteSecured/WriteSecured2 reply can carry the correlated
/// MXAccess outcome instead of proving command acceptance only.
/// </summary>
/// <remarks>
/// Same threading posture as <see cref="MxAccessValueCache"/>: writers and
/// readers run on the worker's STA thread (COM dispatches events on the
/// apartment thread; commands also execute on the STA), so no internal
/// locking is required. A single sync root keeps it nominally thread-safe
/// for tests that drive it from a non-STA thread.
/// </remarks>
public sealed class MxAccessWriteCompletionCache
{
private readonly Dictionary<long, CompletionEntry> entries = new();
private readonly object syncRoot = new();
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="statuses">Status rows from the mapped OnWriteComplete event; cloned before storing.</param>
public void Record(
int serverHandle,
int itemHandle,
RepeatedField<MxStatusProxy> statuses)
{
if (statuses is null)
{
throw new ArgumentNullException(nameof(statuses));
}
lock (syncRoot)
{
long key = CreateItemKey(serverHandle, itemHandle);
ulong version = entries.TryGetValue(key, out CompletionEntry existing)
? existing.Version + 1
: 1UL;
entries[key] = new CompletionEntry(version, statuses.Clone());
}
}
/// <summary>Returns the current completion version for a handle pair, or 0 if none was recorded.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <returns>The current completion version, or 0 if no completion was recorded.</returns>
public ulong CurrentVersion(
int serverHandle,
int itemHandle)
{
lock (syncRoot)
{
return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing)
? existing.Version
: 0UL;
}
}
/// <summary>
/// Polls for a completion newer than <paramref name="sinceVersion"/> until it
/// arrives or the deadline elapses, calling <paramref name="pumpStep"/> on every
/// poll iteration so the worker's STA can dispatch the inbound MXAccess
/// OnWriteComplete message. Same loop shape as
/// <see cref="MxAccessValueCache.TryWaitForUpdate"/>.
/// </summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="sinceVersion">Version snapshot captured before the write COM call.</param>
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
/// <param name="statuses">The recorded status rows if a completion arrived before the deadline; empty otherwise.</param>
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
/// <returns><see langword="true"/> if a completion newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
public bool TryWaitForCompletion(
int serverHandle,
int itemHandle,
ulong sinceVersion,
DateTime deadlineUtc,
Action pumpStep,
out RepeatedField<MxStatusProxy> statuses,
int pollIntervalMs = 5)
{
if (pumpStep is null)
{
throw new ArgumentNullException(nameof(pumpStep));
}
while (true)
{
pumpStep();
lock (syncRoot)
{
if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry)
&& entry.Version > sinceVersion)
{
statuses = entry.Statuses;
return true;
}
}
if (DateTime.UtcNow >= deadlineUtc)
{
statuses = new RepeatedField<MxStatusProxy>();
return false;
}
Thread.Sleep(pollIntervalMs);
}
}
private static long CreateItemKey(
int serverHandle,
int itemHandle)
{
return ((long)serverHandle << 32) | (uint)itemHandle;
}
/// <summary>
/// Snapshot of the most recent OnWriteComplete status rows for a handle
/// pair. <see cref="Version"/> increments by one on every
/// <see cref="Record"/> call so the write executor can detect "a new
/// completion arrived since I captured my baseline".
/// </summary>
/// <remarks>
/// Plain readonly struct (not a record) so this compiles under the
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
/// </remarks>
private readonly struct CompletionEntry
{
public CompletionEntry(
ulong version,
RepeatedField<MxStatusProxy> statuses)
{
Version = version;
Statuses = statuses;
}
public ulong Version { get; }
public RepeatedField<MxStatusProxy> Statuses { get; }
}
}