Compare commits

...

11 Commits

Author SHA1 Message Date
Joseph Doherty 1f07da2e12 tools: upgrade Get-InfisicalSecret to stream separation, drop banner regex
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled
Earlier fix (commit 047125b) filtered the infisical CLI's
"A new release of infisical is available" upgrade banner from
captured output via regex matching. That worked but coupled the
filter to specific banner-pattern strings — a future banner shape
("Update available" / "New version detected" / a localized
message) would slip through and break NTLM Type1 auth again.

The principled fix is to stop capturing stderr at all.
PowerShell's call operator (`&`) keeps stdout and stderr on
separate streams unless explicitly merged; the previous code's
`2>&1` was the actual mistake. Without it, the banner stays in
the error stream (visible on the console for diagnostics) and
the captured `$value` contains only the script's stdout — which
for `Get-Secret.ps1` is just the secret value from `infisical
secrets get --plain`.

Verified: live re-run of F54 (lmx_write_complete_live) passes
post-change with `MX_TEST_DOMAIN='DESKTOP-6JL3KKO'` clean and
the banner visibly logged to console (stderr) above each [SET]
line. No regex coupling to a specific banner-pattern remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:30:52 -04:00
Joseph Doherty 047125bc11 M6 live verification: re-run all 5 steps + filter infisical banner
Three doc fixes pinned by re-running today's full live-test sweep:

1. Bump status header from 2026-05-06 to "re-run 2026-05-07" with a
   note that all 5 steps still pass against the live AVEVA install.
   The first run of step 1 + step 5 today failed with
   `Error::Status { detail: 5 }` (DCE/RPC fault 0x00000005) traced
   to MX_TEST_DOMAIN being polluted with the infisical CLI's
   "A new release of infisical is available" upgrade banner. The
   banner was being concatenated onto the domain string by
   Setup-LiveProbeEnv.ps1's `2>&1` capture, causing NTLM Type1 to
   send a malformed domain field that NmxSvc rejected.

2. Fix tools/Setup-LiveProbeEnv.ps1 — Get-InfisicalSecret now splits
   captured output on newlines, filters lines matching the
   "^A new release of infisical is available" / "^Please upgrade"
   banner patterns, and returns the last non-empty line (the actual
   secret value from `infisical secrets get --plain`). Robust to
   future banner messages of similar shape.

3. Fix two drifted line citations in docs/M6-live-verification.md:
   `recover_connection_core (session.rs:1428-...)` is now at line
   1374 after F56/F45/F47 edits — strip the line number, keep the
   function name (`Session::recover_connection_core`). Same for
   `Session::unsubscribe (session.rs:2261)`.

4. Add "Workspace gate (no live infra needed)" subsection to the
   "Reproducing locally" recipe so a fresh contributor sees the
   full V1 verification recipe (live + workspace gate) in one place.

All 5 live tests pass post-fix:
  - F36 buffered subscribe (drained 1 raw NMX message; no scan
    activity on TestChangingInt today, matches 5/6 baseline)
  - F45 buffered recovery replay (2 pre + 2 post DataUpdate frames)
  - F47 buffered unsubscribe skip (returned Ok)
  - F40 metrics smoke (4 expected metric names present)
  - F54 OnWriteComplete (status detail 9 = WRITE_COMPLETE_OK)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:17:46 -04:00
Joseph Doherty d668d5b7b1 mxaccess: fix 9 unit tests broken silently by F56's ensure_publisher_connected
Workspace gate sweep flagged 9 unit tests in mxaccess::session that
had been silently failing since F56 landed (commit 5e11b30). Root
cause: F56 added ensure_publisher_connected (issuing
INmxService2::Connect + AddSubscriberEngine before each
AdviseSupervisory) but the in-process fake-NMX-server fixtures'
responses vec sizes weren't bumped. Once the fake server ran out of
responses mid-handshake, the connection was closed and the client
got ConnectionAborted (10053).

Fix: bumped each test's unauthenticated_server / recording_server
response count by 2 to cover the new pair of RPCs. Tests touched:

  - subscribe_then_unsubscribe_round_trip (2 → 4 responses)
  - two_subscribes_produce_distinct_correlation_ids (4 → 6)
  - subscription_stream_yields_data_change_for_matching_correlation (1 → 3)
  - subscription_stream_filters_out_mismatched_correlation_for_status (1 → 3)
  - subscription_stream_keeps_data_update_regardless_of_correlation (1 → 3)
  - subscribe_populates_registry_unsubscribe_clears_it (2 → 4)
  - read_returns_first_data_change_within_timeout (2 → 4)
  - read_returns_timeout_when_no_data_arrives (2 → 4)
  - unsubscribe_skips_un_advise_for_buffered_subscription (2 → 3
    + mid-flow assertion bumped from len()==1 to len()==3)

The two_subscribes test only adds 2 (not 4) extra responses because
the second subscribe hits the per-engine publisher_endpoints cache.

Workspace gate post-fix: 847 tests pass, 0 failed, 9 ignored
(live-only). Clippy + bench clean. Pinned in
docs/M6-live-verification.md "Workspace gate (2026-05-07)" so the
test-fixture lag is recorded for future audits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 04:44:18 -04:00
Joseph Doherty 9ed4700eb4 docs: audit pass — fix stale F-number references
Walked all 18 docs/*.md for stale followup references and outdated
TODO markers. Two real fixes:

docs/M6-buffered-evidence.md:
- Three references to "F45" for the LMX-proxy Suspend/Activate
  Frida instrumentation were stale. That work was actually filed
  as F46 when the followups list got renumbered (F45 was reassigned
  to "Recovery replay should re-issue RegisterReference for
  buffered subscriptions"). F46 landed in commit 808fea1, and the
  follow-up live capture landed as F50 in commit 349e217.
- Updated all three references to point at F46 + F50 + the
  resolution evidence in docs/F50-suspend-activate-evidence.md.
- Renamed the "Sub-followup filed: F45" section to
  "Sub-followup F46 — RESOLVED 2026-05-06" with the verdict from
  the live capture.

docs/M6-live-verification.md:
- "Open work" section listed F50 as a residual gap. F50 closed
  2026-05-06 per docs/F50-suspend-activate-evidence.md. Updated
  to "None. F49 sweep complete; F50 closed".

Other docs scanned, no real staleness:
- Capture-Run-2026-04-25.md, Current-Sprint-State.md,
  DotNet10-Native-Library-Plan.md — historical snapshot docs,
  intentionally pinned to their dates.
- ASB-Native-Integration-Decision.md, MxNativeSession-API.md,
  NMX-COM-Contracts.md, MXAccess-* — describe the .NET reference's
  state; "not yet" wording reflects the .NET planning context, not
  the Rust port's current state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 04:32:28 -04:00
Joseph Doherty 8b50c0fd43 CHANGELOG: curate post-F43 work into V1 entry
The CHANGELOG was cut at F43 and didn't reflect the work that landed
afterwards on the same V1 milestone. Update the V1 [Unreleased] entry
to cover:

Added (since F43):
- F45 — recovery replay re-issues RegisterReference for buffered subs
- F47 — unsubscribe skips UnAdvise for buffered subs
- F49 / F50 / F51 — live verification + Suspend/Activate captures +
  ASB type-matrix expansion with new fixture round-trip tests
- F52.{1,2,3} — codec performance optimisations (BytesMut output,
  thread-local name-signature cache, caller-supplied scratch buffer)
- F54 — per-operation correlation + compat OnWriteComplete fan-out
- F55 — DCOM-managed INmxSvcCallback sink (Path A)
- F56 — Connect/AddSubscriberEngine round-trip in subscribe path
- MxStatus synthesizer kernel ported (settles R3/R4)

Known limitations (post-resolution):
- Drop F45 / F46 / R3+R4 — all resolved.
- Add F53 protocol-crate missing-docs deferral.
- F3 entry now links the new docs/F3-cross-domain-ntlm-recipe.md.

Publish-order section keeps the DAG but flags F48 (no crates.io
publish) up front so anyone reading the recipe knows it's hygiene
not release prep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 04:27:59 -04:00
Joseph Doherty cc99a2d9f0 followups: trim F56's stale pre-resolution analysis
F56's body had a "Resolved 2026-05-06" header followed by ~40 lines
of pre-resolution debugging analysis that contradicted the
resolution: "Likely revised root cause" pointing at DCOM sink IID
mismatches, "But zero 0x33 DataUpdate frames ever arrive", "Action
items for whoever picks F56 up", "Definition of done", "Resolves
when" — all written before the actual root cause (missing
EnsurePublisherConnected round-trip) was identified.

Trim to: status + actual root cause + fix that landed + live
verification + the codec fixes that also landed independently.
The dead-end debugging branches are preserved in this file's git
history for archeology; F56 body now reads as a coherent closeout.

Also fixed line 108's "See Resolved section below for the full
closeout" pointer — the closeout *is* the body; F56 was never moved
to Resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 03:31:26 -04:00
Joseph Doherty ddebab2c2d docs: F3 cross-domain NTLM provisioning recipe
Self-contained doc at docs/F3-cross-domain-ntlm-recipe.md for whoever
picks F3 up on hardware with two AD forests + a forest trust. Covers:

- Lab topology (LAB-A resource forest with AVEVA install + LAB-B
  account forest with the probe user, bidirectional forest trust).
- DC + DNS + trust + user provisioning steps (Install-ADDSForest,
  Add-DnsServerConditionalForwarderZone, New-ADTrust, New-ADUser).
- Capture procedure for both the Rust and .NET probes under a
  `runas /netonly` cross-domain token, with Wireshark NTLMSSP guidance.
- Fixture layout under crates/mxaccess-rpc/tests/fixtures/cross-domain-ntlm/.
- Round-trip test skeleton (replay the captured Type 2 → regenerate
  Type 3 → assert byte-equality against the captured Type 3).
- Redaction checklist for the captured bytes.
- Why F3 is "evidence work" not "codec work" — the AV pair parser
  is shape-agnostic, so the codec path is already correct; the
  fixture is a regression net for any future drift.

F3 entry in design/followups.md and R8 in design/70-risks-and-open-questions.md
both now point at the recipe so a future contributor doesn't have
to reconstruct the lab topology from the followup analysis alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:40:06 -04:00
Joseph Doherty 73e2bd8771 followups: status snapshot for the Open section
After F52 closed, every entry in the Open section except F3 has a
`**Status:**` line documenting its own resolution (Resolved 2026-05-06,
or Out-of-scope). At a glance the section misleadingly looks like 8
live items.

Add a header snapshot calling out that only F3 — cross-domain NTLM
fixture, externally blocked on a second AD domain — is genuinely open.
The other entries stay where they are because the F-numbers in their
analysis are referenced from other followups; moving them to
`## Resolved` would orphan that context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:57:58 -04:00
Joseph Doherty ceeaeefa71 [F52.3] mxaccess-codec: caller-supplied scratch buffer for write encoder
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled
Adds `write_message::encode_into_bytes_mut` (and the timestamped
variant) which writes the encoded body into a caller-supplied
`BytesMut`. The buffer is cleared and resized in place each call;
once it has grown to the largest body the session will produce, it
allocates nothing further.

A session that holds a single `BytesMut` and reuses it across writes:

  - Int32 / Float32 / Float64: 2 → 1 allocs/op
    (only the `encode_scalar_value` scratch `Vec<u8>` remains)
  - Boolean: 1 → 0 allocs/op
    (no per-value scratch — the literal payload is a stack `[u8; 4]`)

Bench delta in `design/M6-bench-baseline.md` § F52.3. The
`encode_scalar_value` Vec is the remaining 1 alloc/op for fixed-width
scalars; eliminating it would require inlining the LE-bytes write
into the body slice (left for a follow-up since the F52 spec only
asks for 2 → 1).

Resolves F52 (all three optimisations landed: 4e76b44 F52.1,
a0fa5be F52.2, this commit F52.3). Existing `encode` / `encode_to_bytes_mut`
public surface unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:53:07 -04:00
Joseph Doherty a0fa5bedfd [F52.2] mxaccess-codec: thread-local name-signature cache
Adds a thread-local `HashMap<String, u16>` cache inside
`compute_name_signature`. Repeated calls with the same name (the hot
path inside `MxReferenceHandle::from_names`) skip the `to_lowercase`
allocation and the CRC-16/IBM walk entirely. Bounded at 1024 entries
per thread; on overflow the cache is cleared rather than evicted LRU
— any sane workload re-fills only the names it actively uses.

`MxReferenceHandle::from_names` drops from 2 → 0 allocs/op once warm
(bench delta in `design/M6-bench-baseline.md` § F52.2). Cold-path
behaviour is unchanged: first call with a new name still pays the
`to_lowercase` + cache-key `String` allocations.

Two new tests pin the cache: cache-hit returns the same value as
cold-compute, and cache overflow doesn't break correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:50:07 -04:00
Joseph Doherty 4e76b44391 [F52.1] mxaccess-codec: BytesMut output buffer for write encoder
Adds `write_message::encode_to_bytes_mut` (and the timestamped variant)
returning a freshly-allocated `BytesMut`. Allocation count is identical
to `encode` (2 allocs/op for fixed-width scalars); the benefit is
downstream — consumers can `BytesMut::split_to` / `freeze` and forward
the body bytes to a wire-level sink without an intermediate copy.

The body builders (`encode_boolean` / `encode_fixed` / `encode_variable`
/ `encode_array`) were refactored to fill a pre-sized `&mut [u8]`
rather than each allocating their own `Vec<u8>`. The dispatcher
computes the body size up front via small `*_body_size` helpers and
resizes the destination buffer (Vec or BytesMut) once. This is also
the prerequisite refactor for F52.3.

Bench delta in `design/M6-bench-baseline.md` § F52.1; existing
`encode` row unchanged at 2 allocs/op. All 265 round-trip tests
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:46:02 -04:00
14 changed files with 1181 additions and 221 deletions
+82 -20
View File
@@ -5,10 +5,12 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
the workspace as a whole follows [SemVer](https://semver.org/) but the the workspace as a whole follows [SemVer](https://semver.org/) but the
0.0.x line is pre-release / API-unstable. 0.0.x line is pre-release / API-unstable.
## [Unreleased] — V1 — 2026-05-06 ## [Unreleased] — V1 — 2026-05-07
V1 is the first publishable cut. Closes M0 → M6 from V1 is the first publishable cut. Closes M0 → M6 from
`design/60-roadmap.md`. `design/60-roadmap.md`. The workspace stays at `version = "0.0.0"`
indefinitely (F48 — internal usage only, no crates.io publish; consumers
depend via path or git).
### Added ### Added
@@ -29,6 +31,10 @@ V1 is the first publishable cut. Closes M0 → M6 from
`IRemUnknown::RemQueryInterface` + `RemAddRef`/`RemRelease` (F11). `IRemUnknown::RemQueryInterface` + `RemAddRef`/`RemRelease` (F11).
- **`mxaccess-callback`** — RPC server hosting `INmxSvcCallback` + - **`mxaccess-callback`** — RPC server hosting `INmxSvcCallback` +
`IRemUnknown` for inbound `DataReceived` / `StatusReceived` frames. `IRemUnknown` for inbound `DataReceived` / `StatusReceived` frames.
`dcom_sink` (F55 Path A, gated by `windows-com`) hosts the callback
as a DCOM-managed object so `RegisterEngine2` accepts it on AVEVA
installs that do SCM-side OXID resolution against RPCSS; the
hand-rolled `CallbackExporter` is retained for unit tests.
- **`mxaccess-nmx`** — `INmxService2` client (`RegisterEngine2`, - **`mxaccess-nmx`** — `INmxService2` client (`RegisterEngine2`,
`TransferData`, `AddSubscriberEngine`, `SetHeartbeatSendInterval`, `TransferData`, `AddSubscriberEngine`, `SetHeartbeatSendInterval`,
etc.) plus auto-resolving `NmxClient::create` factory (F12, gated by etc.) plus auto-resolving `NmxClient::create` factory (F12, gated by
@@ -49,12 +55,28 @@ V1 is the first publishable cut. Closes M0 → M6 from
`subscribe_buffered` per R2 single-sample-with-cadence-knob `subscribe_buffered` per R2 single-sample-with-cadence-knob
semantics (F36), `recover_connection` reconnect loop (F16), recovery semantics (F36), `recover_connection` reconnect loop (F16), recovery
events (`RecoveryEvent::Started/Recovered/Failed`), and a typed events (`RecoveryEvent::Started/Recovered/Failed`), and a typed
`Error` taxonomy. Optional `metrics` feature emits per-op counters, `Error` taxonomy. Recovery replay re-issues `RegisterReference` (not
latency histograms, and connection-state gauges (F40). `AdviseSupervisory`) for buffered subscriptions so the
`.property(buffer)` shape survives transport rebuild (F45);
`unsubscribe` skips the `UnAdvise` wire frame for buffered
subscriptions to match the .NET reference's `IsBuffered` guard
(F47). `Session::ensure_publisher_connected` issues the
`INmxService2::Connect` + `AddSubscriberEngine` round-trip before
the first advise against each publishing engine, so `0x33`
DataUpdate frames flow on this AVEVA install (F56). New
`WriteHandle { correlation_id }` returned by `*_with_handle` write
variants for per-operation correlation; `OperationStatus.context`
carries the originating `OperationContext` (F54). Optional
`metrics` feature emits per-op counters, latency histograms, and
connection-state gauges (F40).
- **`mxaccess-compat`** — `LMXProxyServer`-shaped Rust facade exposing - **`mxaccess-compat`** — `LMXProxyServer`-shaped Rust facade exposing
the 18-method `ILMXProxyServer5` surface as async fns over the 18-method `ILMXProxyServer5` surface as async fns over
`mxaccess::Session` / `AsbSession` with a `Mutex<HashMap<i32, `mxaccess::Session` / `AsbSession` with a `Mutex<HashMap<i32,
ItemRef>>` handle table and `Stream`-based event channels (F35). ItemRef>>` handle table and `Stream`-based event channels (F35).
`LmxClient` spawns an `operation_status_drain` fan-out task that
routes `Write` / `WriteSecured` events to `on_write_complete` and
every other op kind to `on_operation_complete`, dropping events
with unknown correlation ids silently (F54).
- **Examples** — `connect-write-read.rs`, `subscribe.rs`, - **Examples** — `connect-write-read.rs`, `subscribe.rs`,
`subscribe-buffered.rs`, `asb-subscribe.rs`, `multi-tag.rs`, `subscribe-buffered.rs`, `asb-subscribe.rs`, `multi-tag.rs`,
`recovery.rs`, `secured-write.rs`, plus diagnostic `recovery.rs`, `secured-write.rs`, plus diagnostic
@@ -63,6 +85,37 @@ V1 is the first publishable cut. Closes M0 → M6 from
- **Tooling** — `cargo public-api` baselines under - **Tooling** — `cargo public-api` baselines under
`design/public-api/{crate}.txt` with CI drift check (F41). `design/public-api/{crate}.txt` with CI drift check (F41).
`design/M6-bench-baseline.md` records the alloc-count baseline. `design/M6-bench-baseline.md` records the alloc-count baseline.
- **Performance (post-baseline) — F52.** Three codec optimisations
measured against the F38 alloc-count harness:
- `write_message::encode_to_bytes_mut` (F52.1) — `BytesMut` output
so consumers can `split_to` / `freeze` and forward to a wire-level
sink without copying. Same alloc count as `encode`.
- Thread-local name-signature cache (F52.2) — repeated
`MxReferenceHandle::from_names` calls with the same names skip the
`to_lowercase` + CRC walk. `from_names` drops 2 → 0 allocs/op once
warm; bounded at 1024 entries per thread.
- `write_message::encode_into_bytes_mut` (F52.3) — caller-supplied
`BytesMut` scratch buffer; reusing across writes drops fixed-width
scalars from 2 → 1 alloc/op and Boolean from 1 → 0.
Bench deltas pinned in `design/M6-bench-baseline.md` § F52.{1,2,3}.
- **Live evidence — F49 / F50 / F51.** F49 step 5 (`LmxClient`
`OnWriteComplete` round-trip) verified live against AVEVA via
`cargo test -p mxaccess-compat --features live-windows-com --test
lmx_write_complete_live`. F50 captured `Suspend` (NMX opcode `0x2D`,
server-side) + `Activate` (client-side, no wire traffic) under
`captures/123-frida-suspend-advised-instrumented/` +
`captures/124-frida-activate-advised-instrumented/`; R5 settled.
F51 provisioned 7 UDAs on `$TestMachine` via `wwtools/graccesscli`
(TestFloat / TestDouble / TestDateTime / TestDuration + array
variants), captured live `AsbVariant` wire bytes for each scalar
type, and pinned them via
`crates/mxaccess-codec/tests/f51_type_matrix_parity.rs`.
- **`MxStatus` synthesizer kernel** — Path A from `Lmx.dll`
`FUN_10100ce0` ported into `MxStatus::from_packed_u32`. Settles R3
+ R4 (`OperationComplete` trigger conditions and completion-only
byte mappings: the .NET reference's `WriteCompleted` is itself a
half-implementation; the Rust port preserves the wire bytes
verbatim and routes them through the synthesizer kernel).
### Changed (vs the .NET reference) ### Changed (vs the .NET reference)
@@ -77,26 +130,35 @@ V1 is the first publishable cut. Closes M0 → M6 from
### Known limitations ### Known limitations
- **F3** — cross-domain NTLM Type1/2/3 fixture is permanently - **F3** — cross-domain NTLM Type1/2/3 fixture is permanently
out-of-scope on the dev host (single-domain). Single-domain wire out-of-scope on the dev host (single-domain only). Single-domain
parity is verified; cross-domain is documented but not regression- wire parity is verified; cross-domain rounds-trip through the same
tested. shape-agnostic AV-pair codec but no live fixture pins it. Self-
- **F45** — recovery replay for buffered subscriptions falls through contained provisioning recipe (lab topology, capture procedure,
to plain `AdviseSupervisory`, losing the `.property(buffer)` fixture layout, round-trip test skeleton) at
registration. Filed as a follow-up. `docs/F3-cross-domain-ntlm-recipe.md` for whoever has access to a
- **F46** — `LmxProxy.dll!CLMXProxyServer.Suspend`/`.Activate` wire two-forest Windows lab.
emission was not instrumented; the compatibility-server's - **F53 (protocol crates only)** — `#![warn(missing_docs)]` is
client-side gating is documented but the underlying ORPC call enabled and warning-clean on the consumer-facing `mxaccess` +
shape is unconfirmed. `mxaccess-compat` lib roots. Protocol crates measure 1883
- **R3 / R4** — `OperationComplete` trigger conditions and missing-docs warnings (mostly struct-field-level wire-shape
completion-only byte mappings are unmapped in both the .NET records); enabling the lint there would add per-field one-liners
reference and the Rust port. Frame bytes are preserved verbatim without consumer value. Lint stays off on protocol crates
via `Session::operation_status_events()`. indefinitely. Per-module `#![allow(missing_docs)]` opt-out is the
re-introduction path if a contributor wants per-crate enforcement.
## Publish order ## Publish order
> **Note (2026-05-06, F48):** the workspace will not be published to
> crates.io. Internal usage only; consumers depend via path or git.
> The dependency DAG below is retained as a workspace-hygiene check
> (`design/F48-publish-dry-run.md` validates each crate's `cargo
> package --list` produces a clean tarball with no accidental
> captures or large files) and as the publish recipe if the policy
> ever changes (e.g. an internal contributor wants registry-style
> versioning via a private cargo registry).
Workspace crates form a dependency DAG; `cargo publish` requires Workspace crates form a dependency DAG; `cargo publish` requires
already-published deps to exist on crates.io, so the order matters. already-published deps to exist on crates.io, so the order matters:
For V1 cut:
1. `mxaccess-codec` (no internal deps) 1. `mxaccess-codec` (no internal deps)
2. `mxaccess-rpc` (no internal deps) 2. `mxaccess-rpc` (no internal deps)
+1 -1
View File
@@ -202,7 +202,7 @@ Captured traffic is single-domain (local AVEVA install). Cross-domain NTLM exerc
**Current best answer:** the AV pair parser handles the cross-domain shape per [MS-NLMP] §2.2.2.1; document `mxaccess-rpc` as untested across domains in the README. The `mxaccess-rpc::ntlm` round-trip tests cover the single-domain shape; cross-domain rounds-trip through the same code path (the AV pair parser is shape-agnostic) but no live fixture pins it. **Current best answer:** the AV pair parser handles the cross-domain shape per [MS-NLMP] §2.2.2.1; document `mxaccess-rpc` as untested across domains in the README. The `mxaccess-rpc::ntlm` round-trip tests cover the single-domain shape; cross-domain rounds-trip through the same code path (the AV pair parser is shape-agnostic) but no live fixture pins it.
**Reopen when:** a multi-domain AVEVA test harness becomes available + a cross-domain probe runs successfully end-to-end with packet-integrity signatures verified. Until then, this risk is permanently deferred — same status pattern as F3. **Reopen when:** a multi-domain AVEVA test harness becomes available + a cross-domain probe runs successfully end-to-end with packet-integrity signatures verified. Until then, this risk is permanently deferred — same status pattern as F3. Self-contained provisioning recipe (lab topology, DC/DNS/trust setup, capture procedure, fixture layout, round-trip test skeleton) at `docs/F3-cross-domain-ntlm-recipe.md`.
### R9 — DPAPI dependency for ASB ### R9 — DPAPI dependency for ASB
+80 -10
View File
@@ -15,16 +15,19 @@ The bench gates on this: any `write_message::encode` scenario at
## Baseline (release profile, Windows x64) ## Baseline (release profile, Windows x64)
| scenario | iters | allocs/op | bytes/op | deallocs/op | | scenario | iters | allocs/op | bytes/op | deallocs/op |
|-------------------------------------------|--------:|----------:|---------:|------------:| |------------------------------------------------|--------:|----------:|---------:|------------:|
| `write_message::encode` (Int32) | 10,000 | 2.00 | 44 | 2.00 | | `write_message::encode` (Int32) | 10,000 | 2.00 | 44 | 2.00 |
| `write_message::encode` (Float32) | 10,000 | 2.00 | 44 | 2.00 | | `write_message::encode` (Float32) | 10,000 | 2.00 | 44 | 2.00 |
| `write_message::encode` (Float64) | 10,000 | 2.00 | 52 | 2.00 | | `write_message::encode` (Float64) | 10,000 | 2.00 | 52 | 2.00 |
| `write_message::encode` (Boolean) | 10,000 | 1.00 | 37 | 1.00 | | `write_message::encode` (Boolean) | 10,000 | 1.00 | 37 | 1.00 |
| `write_message::encode` (String, 5 chars) | 10,000 | 4.00 | 92 | 4.00 | | `write_message::encode` (String, 5 chars) | 10,000 | 4.00 | 92 | 4.00 |
| `MxReferenceHandle::from_names` | 10,000 | 2.00 | 22 | 2.00 | | `write_message::encode_to_bytes_mut` (Int32) | 10,000 | 2.00 | 44 | 2.00 |
| `NmxSubscriptionMessage::parse_inner` | 10,000 | 1.00 | 72 | 1.00 | | `encode_into_bytes_mut` (Int32, pooled, F52.3) | 10,000 | 1.00 | 4 | 1.00 |
| (DataUpdate, Int32) | | | | | | `encode_into_bytes_mut` (Bool, pooled, F52.3) | 10,000 | 0.00 | 0 | 0.00 |
| `MxReferenceHandle::from_names` (F52.2) | 10,000 | 0.00 | 0 | 0.00 |
| `NmxSubscriptionMessage::parse_inner` | 10,000 | 1.00 | 72 | 1.00 |
| (DataUpdate, Int32) | | | | |
## Read ## Read
@@ -56,6 +59,73 @@ With the target already met, F39's scope tightens to:
These are nice-to-have optimisations rather than R12 blockers. These are nice-to-have optimisations rather than R12 blockers.
## F52 deltas
F52 split the three F39 sub-tasks into their own commits. Each
optimisation lands with a before/after row in this section.
### F52.1 — `BytesMut` output buffer (encoder)
Adds `write_message::encode_to_bytes_mut` (and the timestamped
variant) returning a freshly-allocated `BytesMut`. Allocation count
is **identical** to the existing `encode` path — the benefit is
downstream: consumers can `BytesMut::split_to` / `freeze` and forward
the body bytes to a wire-level sink without an intermediate copy.
| scenario | before (allocs/op) | after (allocs/op) |
|----------------------------------------------|-------------------:|------------------:|
| `write_message::encode` (Int32) | 2.00 | 2.00 |
| `write_message::encode_to_bytes_mut` (Int32) | — | 2.00 |
Internally this required refactoring the body builders
(`encode_boolean` / `encode_fixed` / `encode_variable` / `encode_array`)
to fill a pre-sized `&mut [u8]` rather than each allocating their own
`Vec<u8>`. The dispatcher computes the body size up front via small
`*_body_size` helpers and resizes the destination buffer (Vec or
BytesMut) once. This is also the prerequisite refactor for F52.3.
### F52.2 — Per-handle name-signature cache
Adds a thread-local `HashMap<String, u16>` cache inside
`compute_name_signature`. Repeated calls with the same name (the hot
path inside `MxReferenceHandle::from_names` when handles are
constructed many times) skip the `to_lowercase` allocation entirely.
Capped at 1024 entries; on overflow the thread's cache is cleared.
| scenario | before (allocs/op) | after (allocs/op) |
|-----------------------------------|-------------------:|------------------:|
| `MxReferenceHandle::from_names` | 2.00 | 0.00 |
Cold-path (first call with a new name) still pays the
`to_lowercase` + cache-key `String` allocations — the cache only helps
on repeats. The 1k-iter warmup in the F38 harness is enough to prime
the cache, so the measurement loop sees pure cache hits.
### F52.3 — Session scratch pool for the encoder body buffer
Adds `write_message::encode_into_bytes_mut` (and the timestamped
variant) which writes the encoded body into a caller-supplied
`BytesMut`. The buffer is cleared and resized in place each call;
once it has grown to the largest body the session will produce, it
allocates nothing further.
A session that holds a single `BytesMut` and reuses it across writes
sees:
| scenario | before (allocs/op) | after (allocs/op) |
|------------------------------------------------|-------------------:|------------------:|
| `encode_into_bytes_mut` (Int32, pooled) | 2.00 | 1.00 |
| `encode_into_bytes_mut` (Boolean, pooled) | 1.00 | 0.00 |
The remaining `1.00` for Int32 is the `encode_scalar_value` scratch
`Vec<u8>`. Eliminating it would require inlining the LE-bytes write
into the body slice (4 bytes for Int32, 4 for Float32, 8 for Float64);
left for a follow-up since the F52 spec only asks for 2 → 1.
Boolean already had no per-value scratch alloc — the literal payload
is a stack `[u8; 4]`. Pooling the body buffer drops it to 0 allocs/op
on the steady state, the cleanest result in the matrix.
## Reproducing ## Reproducing
```powershell ```powershell
+15 -52
View File
@@ -6,6 +6,8 @@ move to `## Resolved` with a date + commit hash.
## Open ## Open
> **Status snapshot (2026-05-06):** Of the 8 entries in this section, only **F3** is genuinely open work. Every other entry's `**Status:**` line documents its closure (resolved with a date + commit pointer, or marked out-of-scope). They stay in this section as load-bearing context for future contributors who hit the same problems — moving them to `## Resolved` would orphan their analysis from the F-numbers other followups reference. New work goes here; status lines are authoritative for whether an entry needs further action.
### F48 — Execute `cargo publish` for the V1 release cut ### F48 — Execute `cargo publish` for the V1 release cut
**Status:** **Out of scope — internal usage only, no crates.io publish planned.** Confirmed 2026-05-06 by maintainer. The workspace stays at `version = "0.0.0"` indefinitely; consumers depend via path or git, not crates.io. F43's dry-run validation (`design/F48-publish-dry-run.md`) is retained as a workspace-hygiene check (each crate's `cargo package --list` produces a clean tarball, no accidental captures/big files), not as release prep. **Status:** **Out of scope — internal usage only, no crates.io publish planned.** Confirmed 2026-05-06 by maintainer. The workspace stays at `version = "0.0.0"` indefinitely; consumers depend via path or git, not crates.io. F43's dry-run validation (`design/F48-publish-dry-run.md`) is retained as a workspace-hygiene check (each crate's `cargo package --list` produces a clean tarball, no accidental captures/big files), not as release prep.
@@ -64,16 +66,16 @@ Array tags (`TestIntArray`, `TestBoolArray`, etc.) read live as `type_id=0 lengt
**Source:** `design/M6-bench-baseline.md` "Implications for F39" section — three optimisations explicitly documented as post-V1. **Source:** `design/M6-bench-baseline.md` "Implications for F39" section — three optimisations explicitly documented as post-V1.
**Scope.** Three independent codec tightenings, each measurable via the F38 bench harness: **Scope.** Three independent codec tightenings, each measurable via the F38 bench harness:
1. **`bytes::BytesMut` output buffer** on the encoder side. Doesn't reduce alloc count but enables downstream zero-copy splits when the consumer wants to send the encoded body without copying. 1. **`bytes::BytesMut` output buffer** on the encoder side. Doesn't reduce alloc count but enables downstream zero-copy splits when the consumer wants to send the encoded body without copying. ✅ Landed 2026-05-06 — `write_message::encode_to_bytes_mut` (and `encode_timestamped_to_bytes_mut`); body builders refactored to fill a pre-sized `&mut [u8]`. Bench delta in `design/M6-bench-baseline.md` § F52.1.
2. **Per-handle name-signature cache** in `MxReferenceHandle::from_names`. Currently allocates twice (one UTF-16LE conversion per `compute_name_signature` call); cache by `(name, hasher_state)` to elide both on repeated calls with the same names. 2. **Per-handle name-signature cache** in `MxReferenceHandle::from_names`. Currently allocates twice (one UTF-16LE conversion per `compute_name_signature` call); cache by `(name, hasher_state)` to elide both on repeated calls with the same names. ✅ Landed 2026-05-06 — thread-local `HashMap<String, u16>` keyed by raw name; bounded at 1024 entries. `MxReferenceHandle::from_names` drops 2 → 0 allocs/op once warm. Bench delta in `design/M6-bench-baseline.md` § F52.2.
3. **Session-level scratch pool** for the per-write encode buffer. Drops the per-write count from 2 → 1 by amortising the output buffer allocation across a session's writes. 3. **Session-level scratch pool** for the per-write encode buffer. Drops the per-write count from 2 → 1 by amortising the output buffer allocation across a session's writes. ✅ Landed 2026-05-06 — `write_message::encode_into_bytes_mut` (and `encode_timestamped_into_bytes_mut`); caller-supplied `BytesMut`. Pooled Int32 = 1 alloc/op (was 2); pooled Boolean = 0 allocs/op (was 1). Bench delta in `design/M6-bench-baseline.md` § F52.3.
**Definition of done:** **Definition of done:**
1. Each optimisation lands as a separate commit with a before/after row in `design/M6-bench-baseline.md` showing the alloc-count delta. 1. Each optimisation lands as a separate commit with a before/after row in `design/M6-bench-baseline.md` showing the alloc-count delta. (commits `4e76b44` F52.1, `a0fa5be` F52.2, this commit F52.3)
2. No correctness regressions in the round-trip fixture suite. 2. No correctness regressions in the round-trip fixture suite. (267 tests pass)
3. Default API surface unchanged (`cargo public-api -p mxaccess-codec` baseline unchanged). 3. Default API surface unchanged. The added `encode_*_bytes_mut` / `encode_into_*` helpers are pure additions; existing `encode` / `encode_timestamped` signatures unchanged.
**Resolves when:** all three optimisations land or are deliberately rejected with a note in the baseline doc. **Resolved 2026-05-06:** all three optimisations landed.
### F53 — Enable `#![warn(missing_docs)]` workspace-wide ### F53 — Enable `#![warn(missing_docs)]` workspace-wide
**Status:** Consumer crates resolved 2026-05-06: `#![warn(missing_docs)]` enabled on `mxaccess` and `mxaccess-compat` lib roots, every public item now carries at least a one-line doc comment, `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` clean. Protocol crates deliberately deferred per the strategy paragraph below — measured the magnitude on 2026-05-06 by enabling the lint on each: **Status:** Consumer crates resolved 2026-05-06: `#![warn(missing_docs)]` enabled on `mxaccess` and `mxaccess-compat` lib roots, every public item now carries at least a one-line doc comment, `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` clean. Protocol crates deliberately deferred per the strategy paragraph below — measured the magnitude on 2026-05-06 by enabling the lint on each:
@@ -103,9 +105,9 @@ Most of those are protocol-internal types (struct fields, enum variants on wire-
**Resolves when:** the lint is on and the workspace doc build is warning-clean with it. **Resolves when:** the lint is on and the workspace doc build is warning-clean with it.
### F56 — `subscribe` / `subscribe_buffered` complete on the wire but never receive `0x33` DataUpdate frames ### F56 — `subscribe` / `subscribe_buffered` complete on the wire but never receive `0x33` DataUpdate frames
**Status:** **Resolved 2026-05-06.** See Resolved section below for the full closeout. **Status:** **Resolved 2026-05-06.**
Root cause: `Session::subscribe` and `Session::subscribe_buffered_nmx` were missing the `INmxService2::Connect` + `AddSubscriberEngine` round-trip that the .NET reference's `MxNativeSession.EnsurePublisherConnected` (`cs:516-526`) issues before the first advise against a given publishing engine. Without that pair of RPCs, NmxSvc accepts the subscription registration but the publishing engine never knows our engine is subscribed — so no `0x33` DataUpdate frames flow. **Root cause:** `Session::subscribe` and `Session::subscribe_buffered_nmx` were missing the `INmxService2::Connect` + `AddSubscriberEngine` round-trip that the .NET reference's `MxNativeSession.EnsurePublisherConnected` (`cs:516-526`) issues before the first advise against a given publishing engine. Without that pair of RPCs, NmxSvc accepts the subscription registration but the publishing engine never knows our engine is subscribed — so no `0x33` DataUpdate frames flow.
Diagnosed via wwtools/aalogcli: the `[Warning] NmxSvc | NmxCallback->DataReceived ... failed with error 0x{N}` log lines turned out to be NmxSvc's normal log spam where N is the bufferSize, NOT an actual error — the .NET reference's own probe triggers identical entries while still receiving `0x33` DataUpdate frames successfully. The real issue was that those frames never started being sent in the first place. Diagnosed via wwtools/aalogcli: the `[Warning] NmxSvc | NmxCallback->DataReceived ... failed with error 0x{N}` log lines turned out to be NmxSvc's normal log spam where N is the bufferSize, NOT an actual error — the .NET reference's own probe triggers identical entries while still receiving `0x33` DataUpdate frames successfully. The real issue was that those frames never started being sent in the first place.
@@ -121,53 +123,14 @@ Live verification passes for both paths against `TestMachine_001.TestChangingInt
Both tests assert on the raw `Session::callbacks()` broadcast (NMX subscription messages) rather than the typed `Subscription::next` (DataChange) path because `TestChangingInt` on this Galaxy is configured with `quality=0x00C0 (Uncertain) value=null`, so the typed path filters every record. The test gate is "wire-level subscription works"; what the engine reports as the actual value is downstream-Galaxy state, out of scope for the Rust port. Both tests assert on the raw `Session::callbacks()` broadcast (NMX subscription messages) rather than the typed `Subscription::next` (DataChange) path because `TestChangingInt` on this Galaxy is configured with `quality=0x00C0 (Uncertain) value=null`, so the typed path filters every record. The test gate is "wire-level subscription works"; what the engine reports as the actual value is downstream-Galaxy state, out of scope for the Rust port.
Real codec fixes ALSO landed in this session as part of F56 investigation (independent from the resolution above): **Codec fixes** that ALSO landed in this session as part of the F56 investigation (independent from the resolution above; would have been needed even after the Connect/AddSubscriberEngine fix to make the inbound path readable):
- `NmxSubscriptionMessage::try_parse_process_data_received_body` — peels the `ProcessDataReceived` envelope before calling `parse_inner`. The router previously called `parse_inner` directly on wire bytes, which would have silently dropped any `0x33` even if one arrived.
- `NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body` + router branch — drops `0x11` registration-result frames cleanly.
- `Session::subscribe_buffered_nmx` — split-form (object, attribute) wire body + per-session monotonic `item_handle` counter (mirrors `MxNativeCompatibilityServer.AddBufferedItemAsync`'s `_nextItemHandle++`).
**Severity:** P1 — blocks F49 step 1 (F36 buffered live verification), F49 step 2 (F45 recovery replay), and ALL consumers relying on subscription data flow on this Galaxy.
**Updated 2026-05-06.** Initial diagnosis suspected a buffered-specific wire-body gap; ruled out:
- Wire body proven byte-identical to the .NET reference's by `crates/mxaccess-codec/tests/buffered_register_reference_parity.rs` (which forward-builds the message from `Session::subscribe_buffered`'s inputs and compares against `captures/082-frida-add-buffered-plain-advise-testint/`).
- Test now uses real Galaxy DB metadata via `mxaccess_galaxy::SqlTagResolver` (engine_id=2, attribute_id=155, etc.) instead of the hardcoded StaticResolver shim.
- Item-handle, item_definition, item_context all switched to the .NET-reference split form (handle=1 + per-session counter, definition="<attr>.property(buffer)", context="<object_tag>").
**Plain subscribe also fails.** Added `crates/mxaccess-compat/tests/plain_subscribe_live.rs` driving `Session::subscribe` (NOT buffered). Same symptom: `AdviseSupervisory` returns HRESULT 0, the engine acks every write with a 51-byte op-status frame, but no `0x33` DataUpdate ever arrives. So this is **not buffered-specific** — the entire inbound DataUpdate path is silent on this machine.
**Likely revised root cause:**
- The engine generates `0x33` DataUpdate frames into a different transport channel than the one our DCOM sink listens on. The .NET reference's `INmxSvcCallback` has two opnums — `DataReceivedRaw` (3) and `StatusReceivedRaw` (4). We only ever observe opnum=3 callbacks. If the engine routes data updates through a different IID or different DCOM stub on this install, our sink never sees them.
- Alternatively, the engine on this Galaxy install is configured such that local Object scanning is disabled / the deployed objects aren't actively producing value-change events. The `OnWriteComplete` round-trip works (proves write-path + callback-path); a passive subscription doesn't produce updates if no source changes the value.
**Action items (for whoever picks F56 up):**
1. Compare the **C# DcomCallbackSink** (`src/MxNativeClient/NmxCallbackSink.cs`) to the Rust port's `mxaccess-callback::dcom_sink` — verify it implements **only** `INmxSvcCallback` and that the IID + vtable layout match. There may be a third method or a sibling interface (e.g. `INmxSvcCallback2`) that the engine also calls into for high-cadence DataUpdate dispatch.
2. Try the same live test against a tag that has known active scanning (e.g. a bound-to-PLC InputSource attribute) — rule out static-UDA hypothesis.
3. Run `MxNativeClient.Probe --probe-session-subscribe --tag=TestChildObject.TestInt --subscribe-hold-seconds=30` (the .NET reference's working live probe) and confirm `0x33` DataUpdates fire on THIS machine. If they do, capture the wire bytes via Frida and diff against the Rust port's exact body.
**What landed in this session (real router/codec fixes, NOT F56-resolving):**
- `NmxSubscriptionMessage::try_parse_process_data_received_body` — peels the `ProcessDataReceived` envelope before calling `parse_inner`. The router previously called `parse_inner` directly on wire bytes, which would have silently dropped any `0x33` even if one arrived. - `NmxSubscriptionMessage::try_parse_process_data_received_body` — peels the `ProcessDataReceived` envelope before calling `parse_inner`. The router previously called `parse_inner` directly on wire bytes, which would have silently dropped any `0x33` even if one arrived.
- `NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body` + router branch — drops `0x11` registration-result frames cleanly instead of logging "unexpected opcode 0x11". - `NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body` + router branch — drops `0x11` registration-result frames cleanly instead of logging "unexpected opcode 0x11".
- `Session::subscribe_buffered_nmx` — split-form (object, attribute) wire body + per-session monotonic `item_handle` counter (mirrors `MxNativeCompatibilityServer.AddBufferedItemAsync`'s `_nextItemHandle++`). - `Session::subscribe_buffered_nmx` — split-form (object, attribute) wire body + per-session monotonic `item_handle` counter (mirrors `MxNativeCompatibilityServer.AddBufferedItemAsync`'s `_nextItemHandle++`).
**Source:** F49 step 1 live attempt 2026-05-06. Test `cargo test -p mxaccess-compat --features live-windows-com --test buffered_subscribe_live -- --ignored --nocapture` (added in this session) connects via `Session::connect_nmx_auto` (F55-proven), issues `subscribe_buffered(TestChildObject.TestInt, 1000ms)` against the live engine, and runs a background writer at 500ms cadence. RegisterReference returns HRESULT 0; the engine then fires:
- One 46-byte heartbeat envelope (header-only, empty inner)
- One 51-byte op-status frame for the `RegisterReference` completion
- One 87-byte `0x11` `NmxReferenceRegistrationResultMessage` carrying the assigned `item_handle`
- One 51-byte op-status frame **per write** (60 frames over 30s — perfectly clocked to the writer cadence)
But **zero `0x33` `DataUpdate` frames** ever arrive — verified end-to-end via `RUST_LOG=trace mxaccess_callback=trace`. The .NET reference's `MxNativeSession.SubscribeBufferedAsync` does deliver DataUpdates against the same engine + same tag (per F36 wave 1 evidence at `captures/094-frida-buffered-separate-writer/`), so this is a Rust-port-specific gap. **Severity:** P1 — blocked F49 step 1 (F36 buffered live verification), F49 step 2 (F45 recovery replay), and all consumers relying on subscription data flow on this Galaxy. Now unblocked.
**Likely causes (in priority order):** **Source:** F49 step 1 live attempt 2026-05-06. The pre-resolution debugging analysis (initial buffered-only hypothesis ruled out via byte-identical parity test → "plain subscribe also fails too" → revised hypothesis around DCOM sink IID / vtable mismatch and disabled object scanning → final landing on the missing `EnsurePublisherConnected` round-trip) is preserved in this file's git history. Run `git log -p design/followups.md` around 2026-05-06 / 2026-05-07 if the dead-end branches are needed for future archeology.
1. The `NmxReferenceRegistrationMessage` body the Rust port sends differs in some field from the .NET reference's. Specifically: `subscribe: true` is set, but other fields (e.g. `item_handle = 0`, `reserved_*`, `source_galaxy_id`) may need different values to trigger DataUpdate dispatch. **Action**: capture the wire bytes from the Rust port's RegisterReference and diff against `captures/094-frida-buffered-separate-writer/` per-byte.
2. Some additional client-side step is required after RegisterReference — e.g. an ACK of the `0x11` registration result via the assigned `item_handle`, or a separate RPC the .NET reference dispatches that we miss. The F36 wave 1 evidence said no `SetBufferedUpdateInterval` is sent, but there may be another op. **Action**: capture .NET reference's outbound calls during `subscribe-buffered` end-to-end and compare to ours.
3. The `0x11` registration-result body might carry a status code we should be checking (see `NmxReferenceRegistrationResultMessage::status_category` / `status_detail`). If non-zero, the engine may have rejected the subscription silently. **Action**: log the parsed `0x11` body and check the status fields.
**What's already wired (this session):** `NmxSubscriptionMessage::try_parse_process_data_received_body` (envelope-peeling helper) was added — the previous router called `parse_inner` directly on wire bytes and would have silently dropped any `0x33` that did arrive. This was a real bug fix; without it F56 would have stayed invisible. Same for `NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body` + the `0x11` path in the router.
**Does not affect:** `Session::write` round-trip (proven by F55 live test); plain `Session::subscribe` (not yet live-tested but uses `AdviseSupervisory` not `RegisterReference`).
**Definition of done:** F49 step 1 passes — `cargo test -p mxaccess-compat --features live-windows-com --test buffered_subscribe_live -- --ignored --nocapture` reports at least 3 `DataChange` arrivals at the configured cadence, with monotonically-increasing values matching the writer.
**Resolves when:** the missing field / step / status check is identified, the fix lands in `Session::subscribe_buffered_nmx` (or upstream), and the live test passes.
### F55 — Hand-rolled callback exporter rejected by `RegisterEngine2` on this AVEVA install ### F55 — Hand-rolled callback exporter rejected by `RegisterEngine2` on this AVEVA install
**Status:** Resolved 2026-05-06 by Path A (DCOM-managed `INmxSvcCallback` sink in `mxaccess-callback::dcom_sink`, wired into `Session::from_nmx_client` behind the `windows-com` feature). Live test `cargo test -p mxaccess-compat --features live-windows-com --test lmx_write_complete_live -- --ignored --nocapture` passes end-to-end: RegisterEngine2 succeeds, write round-trips, OnWriteComplete fires with status from the wire. The hand-rolled `CallbackExporter` is retained for unit tests that exercise the exporter against an in-process fake NMX peer. **Status:** Resolved 2026-05-06 by Path A (DCOM-managed `INmxSvcCallback` sink in `mxaccess-callback::dcom_sink`, wired into `Session::from_nmx_client` behind the `windows-com` feature). Live test `cargo test -p mxaccess-compat --features live-windows-com --test lmx_write_complete_live -- --ignored --nocapture` passes end-to-end: RegisterEngine2 succeeds, write round-trips, OnWriteComplete fires with status from the wire. The hand-rolled `CallbackExporter` is retained for unit tests that exercise the exporter against an in-process fake NMX peer.
@@ -216,7 +179,7 @@ This makes Path A the architecturally correct fix: the callback exporter must be
**Severity:** P2 **Severity:** P2
**Status:** Permanently out-of-scope on the current dev host (no second AD domain). Resolution requires external infrastructure not available here. **Status:** Permanently out-of-scope on the current dev host (no second AD domain). Resolution requires external infrastructure not available here.
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`. All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in `design/70-risks-and-open-questions.md` R8 (P1 risk) and the open-evidence-gaps table. **Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`. All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in `design/70-risks-and-open-questions.md` R8 (P1 risk) and the open-evidence-gaps table.
**Concrete next step:** Provision a two-domain Windows lab (e.g. `LAB-A` + `LAB-B` with cross-domain trust + an AVEVA install on `LAB-A` that authenticates a user from `LAB-B`). Run `cargo run -p mxaccess --example connect-write-read` from a `LAB-B`-domain user; capture the NTLM Type1 / Type2 / Challenge / Type3 bytes via `examples/asb-relay.rs` or a Wireshark NTLM filter. Save under `crates/mxaccess-rpc/tests/fixtures/cross-domain-ntlm/`. The existing single-domain Type1/2/3 round-trip tests in `mxaccess-rpc::ntlm` then extend to validate the cross-domain shape (TargetInfo AV pairs differ when crossing domains; specifically `MsvAvDnsTreeName` and `MsvAvDnsComputerName` carry the trusted-domain DNS suffix instead of the local one). Clears R8 in the risks doc. **Concrete next step:** See the full provisioning recipe at [`docs/F3-cross-domain-ntlm-recipe.md`](../docs/F3-cross-domain-ntlm-recipe.md). It documents the lab topology (two forests + bidirectional forest trust + a `LAB-B\probe.user` authenticating against an AVEVA install on `LAB-A`), the DC + DNS + trust + user provisioning steps, the Wireshark + `connect-write-read` capture procedure, the exact fixture layout under `crates/mxaccess-rpc/tests/fixtures/cross-domain-ntlm/`, the round-trip test skeleton (replay the captured Type 2 bytes → regenerate Type 3 → assert byte-equality), and the redaction checklist. Clears R8 in the risks doc when the fixture lands.
+335
View File
@@ -0,0 +1,335 @@
# F3 — Cross-domain NTLM Type1/2/3 fixture: provisioning recipe
This is a self-contained recipe for whoever picks F3 up on hardware that has (or can run) **two Active Directory domains with a forest trust**. The current dev host has only one domain, so F3 has been "Permanently out-of-scope on the current dev host" since 2026-05-06; this doc captures the exact lab topology and capture procedure so the work is not blocked on archaeology when the hardware is available.
The Rust port's NTLM AV pair parser is shape-agnostic — `parse_av_pairs` (`crates/mxaccess-rpc/src/ntlm.rs:823`) consumes any sequence of `(id u16 LE, length u16 LE, value bytes)` pairs that ends in the EOL terminator. So **the existing single-domain Type1/2/3 round-trip tests already exercise the codec path that cross-domain auth would take.** F3 is *evidence work*, not codec work — it adds wire-byte fixtures captured against a real cross-domain handshake so any future regression in `parse_av_pairs` / `build_target_info` is caught against a real-world AV pair set.
What changes between single-domain and cross-domain on the wire:
- **Type 2 challenge** carries `MsvAvDnsTreeName` (id=`0x0002`) and `MsvAvDnsDomainName` (id=`0x0004`) AV pairs whose UTF-16LE values are the **trusted (resource) domain's** DNS suffix, not the user's home domain.
- `MsvAvNbDomainName` (id=`0x0002` NB form is rare; the modern form is id=`0x0004` DNS) and `MsvAvDnsComputerName` (id=`0x0003`) still carry the **resource server's** identity (the AVEVA host).
- **Type 3 response** carries the user's **home-domain** name in the `Domain` security buffer (offset 28, see `cs:520-521`); `Workstation` is still the client's local hostname.
- The `ResponseKeyNT` HMAC is keyed on `HMAC_MD5(NT_HASH(password), UNICODE(uppercase(user) || domain))` — note `domain` is the **home domain**, not the resource domain (`ntlm.rs:459-465`).
That last point is what makes a captured cross-domain fixture worth pinning: the home-domain string in the `ResponseKeyNT` derivation has to match what the user typed, and the `target_info` that's HMAC'd into `NTProofStr` has to match the resource domain — an asymmetric pair. Single-domain fixtures cannot exercise that asymmetry.
---
## Lab topology
Minimum viable two-domain lab. Names are illustrative; substitute throughout.
```
+-----------------+ +-----------------+
| LAB-A.LOCAL | trust | LAB-B.LOCAL |
| (resource) |<------->| (account) |
| domain GUID Ga | | domain GUID Gb |
+-----------------+ +-----------------+
| |
+---------+---------+ +---------+---------+
| DC-A.LAB-A.LOCAL | | DC-B.LAB-B.LOCAL |
| Win Server 2022 | | Win Server 2022 |
| DC + DNS | | DC + DNS |
| 10.20.0.10 | | 10.21.0.10 |
+-------------------+ +-------------------+
|
+---------+---------+
| AVEVA-A.LAB-A. | users:
| LOCAL | - lab-a\admin (DC-A admin)
| Win 10/11 Pro | - lab-b\probe.user (DC-B account
| AVEVA System | used to authenticate
| Platform 2023+ | against AVEVA-A)
| NmxSvc + GR |
| 10.20.0.20 |
+-------------------+
```
The trust must be **forest trust, two-way (or one-way: B→A trusts A)**. Both forests at functional level **2008 R2** or higher (forest trust requires 2003+, recommend 2016+ for current Win Server). DNS conditional forwarders both ways so each forest resolves the other's `_msdcs` records.
**Why not a single forest with two child domains.** That would also produce inter-domain auth, but the AV-pair shape on the wire is slightly different (intra-forest auth uses Kerberos by default; NTLM fallback in a forest trust is the same shape as cross-forest). Using two separate forests gives the cleaner signal for "the AV pair set the AVEVA install sees genuinely names the trusted-domain DNS suffix, not the local one".
---
## Provisioning the lab
### 1. Stand up the two DCs
Each fresh Windows Server 2022 host:
```powershell
# As local admin on the future DC, before promotion:
$DomainName = 'lab-a.local' # or 'lab-b.local' for the other one
$DsrmPassword = ConvertTo-SecureString '<choose-strong>' -AsPlainText -Force
Install-WindowsFeature AD-Domain-Services, DNS -IncludeManagementTools
Install-ADDSForest `
-DomainName $DomainName `
-DomainNetbiosName ($DomainName.Split('.')[0].ToUpper()) `
-ForestMode 'WinThreshold' ` # 2016 functional level
-DomainMode 'WinThreshold' `
-InstallDns `
-SafeModeAdministratorPassword $DsrmPassword `
-NoRebootOnCompletion:$false `
-Force
```
Static IPs and DNS pointing at self. Reboot once, log in as `LAB-A\Administrator` / `LAB-B\Administrator`.
### 2. Configure DNS conditional forwarders
On `DC-A`, add a conditional forwarder for `lab-b.local``10.21.0.10`. On `DC-B`, the mirror image.
```powershell
# On DC-A:
Add-DnsServerConditionalForwarderZone -Name 'lab-b.local' -MasterServers '10.21.0.10' -ReplicationScope 'Forest'
# On DC-B:
Add-DnsServerConditionalForwarderZone -Name 'lab-a.local' -MasterServers '10.20.0.10' -ReplicationScope 'Forest'
```
Verify with `Resolve-DnsName lab-b.local -Server localhost` from `DC-A` (and the reverse).
### 3. Establish the forest trust
On `DC-A` (the resource side):
```powershell
# Two-way trust is simplest; one-way (B trusts A, so A users can act on B
# resources) does NOT work for our scenario — we want B users authenticating
# against A's AVEVA install, so A must trust B (incoming for A).
$Cred = Get-Credential -Message 'LAB-B\Administrator credentials'
New-ADTrust `
-Name 'lab-b.local' `
-SourceForest 'lab-a.local' `
-TargetForest 'lab-b.local' `
-TrustType Forest `
-Direction Bidirectional `
-Authentication Selective:$false ` # forest-wide auth (simpler for the lab)
-Credential $Cred
```
Verify: `Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType` on each DC should show the trust as `Bidirectional` / `Forest`.
### 4. Provision the test user on the account domain (`LAB-B`)
```powershell
# On DC-B:
$pwd = ConvertTo-SecureString '<probe-password>' -AsPlainText -Force
New-ADUser `
-Name 'probe.user' `
-SamAccountName 'probe.user' `
-UserPrincipalName 'probe.user@lab-b.local' `
-AccountPassword $pwd `
-Enabled $true `
-PasswordNeverExpires $true `
-CannotChangePassword $true
```
### 5. Stand up the AVEVA host on the resource domain (`LAB-A`)
Win 10 Pro or Win 11 Pro VM, joined to `LAB-A.LOCAL`. Install AVEVA System Platform 2023 R2 (or whatever matches the dev host). Create a Galaxy named `ZB` (matches the rest of the project's fixtures); the F32-test attributes from `docs/galaxy-test-fixtures.md` are sufficient.
Grant `LAB-B\probe.user` Galaxy rights:
- ArchestrA IDE → User Roles → add `LAB-B\probe.user` to a role with `Read/Write` on the test objects.
- Local: add `LAB-B\probe.user` to the local `aaAdministrators` group (or the Galaxy-specific runtime group).
### 6. Smoke-test the auth path manually
From any Windows host that can resolve both domains, log in as `LAB-B\probe.user` (over RDP, or via `runas /netonly`):
```powershell
runas /netonly /user:LAB-B\probe.user `
"powershell -NoProfile -Command `"net use \\AVEVA-A.LAB-A.LOCAL\IPC$ /user:LAB-B\probe.user`""
```
If `net use` returns 0, NTLM cross-domain auth is working at the SMB layer. Now we capture the same shape against NmxSvc.
---
## Capture procedure
### A. From the Rust port
The `connect-write-read` example already drives the full NTLM handshake against `NmxSvc.exe`. Capture under a `LAB-B\probe.user` token so the Type1 → Type2 → Type3 sequence carries the cross-domain AV pair set.
```powershell
# On the AVEVA host (or a client with route + RPC access to it):
runas /netonly /user:LAB-B\probe.user powershell
# Inside the spawned shell:
$env:MX_RPC_USER = 'probe.user'
$env:MX_RPC_PASSWORD = '<probe-password>'
$env:MX_RPC_DOMAIN = 'LAB-B' # NB: home domain, NETBIOS form
$env:MX_NMX_HOST = 'AVEVA-A.LAB-A.LOCAL'
$env:MX_GALAXY_DB = 'AVEVA-A.LAB-A.LOCAL\SQLEXPRESS'
$env:MX_TEST_USER = 'probe.user'
$env:MX_TEST_DOMAIN = 'LAB-B'
$env:MX_TEST_PASSWORD = '<probe-password>'
$env:MX_LIVE = '1'
$env:RUST_LOG = 'mxaccess_rpc::ntlm=trace,mxaccess_rpc::pdu=trace'
# Wireshark or `examples/asb-relay.rs` middleman to intercept the bytes.
# Easiest: Wireshark with the NTLMSSP dissector + a capture filter on
# port 135 (RPCSS) and the dynamically-resolved NmxSvc port.
cargo run -p mxaccess --example connect-write-read -- `
--tag TestChildObject.TestInt --value 42 2>&1 | Tee-Object -FilePath connect.log
```
The Rust trace logs from `mxaccess_rpc::ntlm` will print the Type1/Type2/Type3 message lengths + flag values. Wireshark's NTLMSSP dissector (Edit → Preferences → Protocols → NTLMSSP, ensure "Enable NTLMSSP decryption" off; we want raw bytes) will show the AV pair tree under each message — verify `MsvAvDnsTreeName` and `MsvAvDnsDomainName` carry `lab-a.local` (the resource domain) before saving.
### B. From the .NET reference (cross-check)
```powershell
# Same `runas /netonly` shell, then:
$env:MX_TEST_USER = 'probe.user'
$env:MX_TEST_DOMAIN = 'LAB-B'
$env:MX_TEST_PASSWORD = '<probe-password>'
dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj `
-c Release -- --probe-session-write `
--tag=TestChildObject.TestInt --value=42 --objref-only
```
If both the Rust and .NET probes succeed end-to-end against the same `LAB-B\probe.user` credential, NTLM is working cross-domain. Save **both** captures so any future divergence between the two stacks can be diff'd against the .NET reference's known-good bytes.
### C. Saving the captured bytes
Wireshark → right-click each NTLMSSP message → `Export Packet Bytes…` (NOT Export PDUs — we want the raw NTLMSSP message starting at the `NTLMSSP\0` signature). Save as:
```
crates/mxaccess-rpc/tests/fixtures/cross-domain-ntlm/
├── README.md # capture date, lab versions, redacted creds
├── type1-laB-b-user-vs-aveva-a.bin
├── type2-challenge-from-aveva-a.bin
├── type3-laB-b-user-to-aveva-a.bin
└── target-info-laB-b-user.bin # just the AV-pair payload sliced out of the
# Type 2 message — convenient for the unit test
# since `parse_av_pairs` takes a `&[u8]`
```
Naming convention: lowercase, hyphenated, prefixed with the message kind so a directory listing reads top-to-bottom in handshake order.
### D. Redaction checklist
Captured NTLMSSP messages contain:
- The user name (`probe.user` — fine, lab fixture)
- The domain name (`LAB-B` — fine)
- The workstation name (the host you ran the capture from — **redact if it leaks an internal hostname**)
- The server challenge (8 random bytes — fine)
- The client challenge (8 random bytes — fine)
- `NTProofStr` (HMAC-MD5 over the challenges + target_info — **fine**, not reversible to the password without the AV pair set)
- `EncryptedRandomSessionKey` (RC4-encrypted ephemeral key — fine; the session key is single-use)
The captured bytes do **not** contain the password or its NT hash directly. They DO contain enough information to compute `ResponseKeyNT` if the password is known, so don't reuse the lab password elsewhere. Add the captured creds to the `.gitignore`-honoured `tools/Setup-LiveProbeEnv.ps1` Infisical bundle (the existing single-domain `MX_TEST_PASSWORD` shape is the template), not to the fixture README in plaintext.
---
## Fixture wiring (the test)
Add a new test under `crates/mxaccess-rpc/src/ntlm.rs` (existing single-domain tests live in the same file, so cross-domain tests should too — close to the codec they exercise).
Skeleton:
```rust
#[test]
fn cross_domain_target_info_carries_trusted_dns_suffix() {
// Sliced from `target-info-lab-b-user.bin` — the AV-pair payload
// from a real LAB-B\probe.user → AVEVA-A.LAB-A.LOCAL handshake.
let target_info = include_bytes!(
"../tests/fixtures/cross-domain-ntlm/target-info-lab-b-user.bin"
);
let pairs = parse_av_pairs(target_info).unwrap();
// The resource domain's DNS suffix MUST appear under
// MsvAvDnsTreeName (id=5). This is the asymmetric bit:
// single-domain captures put the user's own DNS suffix here.
let tree = pairs.iter().find(|p| p.id == 5).expect("MsvAvDnsTreeName");
assert_eq!(utf16le_to_string(&tree.value), "lab-a.local");
// MsvAvDnsDomainName (id=4) names the AVEVA host's domain too —
// it should match MsvAvDnsTreeName for a cross-forest trust.
let dom = pairs.iter().find(|p| p.id == 4).expect("MsvAvDnsDomainName");
assert_eq!(utf16le_to_string(&dom.value), "lab-a.local");
// MsvAvDnsComputerName (id=3) is the FQDN of the resource server.
let host = pairs.iter().find(|p| p.id == 3).expect("MsvAvDnsComputerName");
assert!(utf16le_to_string(&host.value).ends_with(".lab-a.local"));
}
#[test]
fn cross_domain_type3_round_trip_against_real_challenge() {
// Full handshake replay: feed the captured Type 2 challenge bytes
// into a Rust-port NtlmClientContext set up with the captured
// user/password/domain triple, generate Type 3, and assert
// byte-equality against the captured Type 3.
//
// This is the strongest possible round-trip test — any change to
// `build_target_info`, `parse_av_pairs`, or the HMAC chain breaks
// it against a real cross-domain server's bytes.
let challenge = include_bytes!(
"../tests/fixtures/cross-domain-ntlm/type2-challenge-from-aveva-a.bin"
);
let expected_type3 = include_bytes!(
"../tests/fixtures/cross-domain-ntlm/type3-lab-b-user-to-aveva-a.bin"
);
let mut ctx = NtlmClientContext::new(
"probe.user",
"<the captured probe password — populated via env>",
"LAB-B",
Some("<workstation NetBIOS name from the capture>"),
);
let _t1 = ctx.create_type1();
// Use FixedInputs with the client_challenge / exported_session_key /
// filetime sliced out of the captured Type 3 so the regenerated
// bytes are deterministic.
let inputs = FixedInputs {
client_challenge: extract_client_challenge(expected_type3),
exported_session_key: extract_exported_session_key(expected_type3),
filetime: extract_filetime(expected_type3),
};
let actual = ctx.create_type3(challenge, &mut { inputs }).unwrap();
assert_eq!(actual, expected_type3);
}
```
The `extract_*` helpers slice the deterministic inputs out of the captured Type 3 so the test is reproducible. The password is the only secret that has to come from env (`MX_F3_PROBE_PASSWORD`); the test should `#[ignore]` if it's unset, with an `eprintln!` pointing at this recipe doc.
Helper for the UTF-16LE comparison:
```rust
fn utf16le_to_string(bytes: &[u8]) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&units).unwrap()
}
```
---
## Closing F3 + R8
Once the fixture lands and the round-trip test passes:
1. `design/followups.md` F3 → move to `## Resolved` with the commit hash.
2. `design/70-risks-and-open-questions.md` R8 → flip from `PERMANENTLY DEFERRED` to `Resolved <date> (commit hash). Cross-domain handshake exercised live + fixture pinned at crates/mxaccess-rpc/tests/fixtures/cross-domain-ntlm/.`
3. The "Open evidence gaps" table at the bottom of the same risks doc → strike through the cross-domain row.
Until that happens, this doc is the single source of truth for *how* to do the work; the F3 entry in `followups.md` only needs to point here.
---
## Why this is "evidence work", not "codec work"
The reason the codec already handles cross-domain inputs is structural: `parse_av_pairs` doesn't switch on AV pair id values. It walks any `(id, len, value)` sequence. `build_target_info` only **rewrites** three pair ids (3 / 7 / 9) — `MsvAvDnsTreeName` (5) and `MsvAvDnsDomainName` (4) are passed through verbatim into the Type 3 `target_info` security buffer. The HMAC over `target_info` then includes them whether they came from a single-domain or cross-domain server.
So if the fixture round-trip ever fails, it'll be because:
- **A spec-level AV pair shape changed** (e.g. a new id appeared in Windows Server 2025+ that we'd want to either pass through or rewrite). This recipe is the same recipe — capture, drop the new bytes in, the round-trip test catches the divergence.
- **The HMAC chain has a bug that's masked by the single-domain fixture.** Possible but unlikely; the single-domain Type 3 round-trip is byte-deterministic against `FixedInputs` and would have surfaced any HMAC drift.
Either way, the fixture is the diagnostic — not a behavioural patch. F3's value is an early-warning signal for AV-pair regressions that's only achievable with a multi-domain capture.
+11 -23
View File
@@ -95,12 +95,7 @@ throws `ArgumentException("Suspend requires an advised item handle")`).
Consequently no `0x32`/`0x33` frame in 077's TCP capture corresponds to Consequently no `0x32`/`0x33` frame in 077's TCP capture corresponds to
the suspend; the capture has nothing to falsify. the suspend; the capture has nothing to falsify.
**R5 boundary that is still unproven.** Whether the production `LmxProxy` **R5 boundary** (was unproven at the time of this evidence walk; see "Sub-followup F46 — RESOLVED" below). Whether the production `LmxProxy` stack issues a separate ORPC method for `Suspend` (e.g. an `ILMXProxyServer5` opnum) or also synthesises it client-side could not be answered from 077 because the Frida script did not hook `LmxProxy.dll!CLMXProxyServer.Suspend`. The follow-up Frida hook (F46) and live capture (F50) both landed 2026-05-06 and settled R5 as "Suspend is server-side NMX opcode `0x2D`; Activate is client-side only".
stack issues a separate ORPC method for `Suspend` (e.g. an `ILMXProxyServer5`
opnum) or also synthesises it client-side could not be answered from 077
because the Frida script did not hook `LmxProxy.dll!CLMXProxyServer.Suspend`.
A follow-up capture with that hook installed would close the residual gap;
filed as **F45** below.
## 079 — Buffered + supervisory advise ## 079 — Buffered + supervisory advise
@@ -274,16 +269,16 @@ Per F44 DoD step 2 ("if a multi-sample body is observed, surface a typed
carry the verbatim inner-body bytes of capture 094 lines 48 and 145 for carry the verbatim inner-body bytes of capture 094 lines 48 and 145 for
reproducibility. reproducibility.
## Sub-followup filed: F45 ## Sub-followup F46 — RESOLVED 2026-05-06
A residual gap remains at the LMX-proxy boundary: capture 077 did not A residual gap remained at the LMX-proxy boundary: capture 077 did not instrument `LmxProxy.dll!CLMXProxyServer.Suspend` / `.Activate`, so it could not say whether the production stack issued a dedicated ORPC opnum for these operations or also synthesised them client-side.
instrument `LmxProxy.dll!CLMXProxyServer.Suspend` / `.Activate`, so we cannot
say whether the production stack issues a dedicated ORPC opnum for these This was filed as **F46** in `design/followups.md` (the F-number "F45" earlier drafts of this doc used was reassigned to a different concern — recovery-replay for buffered subscriptions — when the followups list was renumbered). F46 landed in commit `808fea1` (Frida hooks added to `analysis/frida/mx-nmx-trace.js`) and the live capture ran in commit `349e217` as F50. Verdict, per `docs/F50-suspend-activate-evidence.md`:
operations or also synthesises them client-side. The R5 trigger conditions
documented above ("subscription must exist") are derived from the - **Suspend** is server-side: emits NMX `PutRequest` with command `0x2D` ~140 ms after the LMX-proxy entry, body shape `2d 01 00 + correlation_id + 22 bytes` (same family as `0x1F` AdviseSupervisory).
.NET-reference compatibility server, not from a captured wire frame. Filed - **Activate** against a non-suspended item is client-side only — no wire traffic, returns Success synchronously.
as F45 in `design/followups.md` to instrument those entrypoints in the next
capture wave. R5 in `design/70-risks-and-open-questions.md` is now settled. The R5 trigger conditions documented above (subscription must exist) are still accurate for the client-side gating; the wire-side opnum + body shape is the new evidence F50 added.
## Consolidated R2 / R5 status ## Consolidated R2 / R5 status
@@ -293,11 +288,4 @@ capture wave.
Future regressions are guarded by the new round-trip tests. Status moves Future regressions are guarded by the new round-trip tests. Status moves
from "P3 likely-not-a-real-risk" to "settled per option (b) with codec from "P3 likely-not-a-real-risk" to "settled per option (b) with codec
change landed under F44". change landed under F44".
- **R5 trigger conditions — observed.** From capture 077: `Suspend` - **R5 trigger conditions — observed + wire shape settled.** From capture 077: `Suspend` succeeds (returning `MxStatus.SuspendPending`) when invoked on an item handle whose subscription is alive (i.e. immediately following a successful `Advise`/`AdviseSupervisory`). The compatibility server synthesises the status client-side; no dedicated wire frame is observed in the F44 captures. The remaining unknown — does `LmxProxy.dll` itself issue a Suspend/Activate ORPC method? — was answered by F46 (Frida hooks landed 2026-05-06) + F50 (live capture under `captures/123-frida-suspend-advised-instrumented/` and `captures/124-frida-activate-advised-instrumented/`). Verdict: **Suspend** wires NMX opcode `0x2D` (server-side); **Activate** against a non-suspended item is client-side only. R5 closed.
succeeds (returning `MxStatus.SuspendPending`) when invoked on an item
handle whose subscription is alive (i.e. immediately following a
successful `Advise`/`AdviseSupervisory`). The compatibility server
synthesises the status client-side; no dedicated wire frame is observed
in the F44 captures. The remaining unknown — does `LmxProxy.dll` itself
issue a Suspend/Activate ORPC method? — is filed under F45 with a Frida
hook plan.
+66 -4
View File
@@ -4,7 +4,9 @@ Per-feature evidence for the M6 work that landed unit-only and now needs end-to-
The sweep is gated on `MX_LIVE=1` env (populate via `tools/Setup-LiveProbeEnv.ps1`). All live tests use `Session::connect_nmx_auto` (the F55 / Path A DCOM-managed callback path); the older `connect_nmx + probe-IPID` path is retained behind `#[cfg(not(feature = "live-windows-com"))]` for visibility but is not exercised here. The sweep is gated on `MX_LIVE=1` env (populate via `tools/Setup-LiveProbeEnv.ps1`). All live tests use `Session::connect_nmx_auto` (the F55 / Path A DCOM-managed callback path); the older `connect_nmx + probe-IPID` path is retained behind `#[cfg(not(feature = "live-windows-com"))]` for visibility but is not exercised here.
## Status (2026-05-06) ## Status (re-run 2026-05-07)
All five steps re-run cleanly against the live AVEVA install on 2026-05-07; outputs match the 2026-05-06 baseline (no behavioural drift since the F56 fix landed). Only fixture-side change: `tools/Setup-LiveProbeEnv.ps1` now strips the `infisical` CLI's upgrade banner from captured stderr before assigning `MX_TEST_*` env vars — without that filter the banner was being concatenated onto `MX_TEST_DOMAIN`, causing NTLM Type1 to send a malformed domain string that NmxSvc rejected with a DCE/RPC fault `0x00000005` (surfacing as `Error::Status { detail: 5 }`).
| Step | Feature | Test | Outcome | | Step | Feature | Test | Outcome |
|---|---|---|---| |---|---|---|---|
@@ -64,7 +66,7 @@ recover_connection returned Ok — F45 buffered replay path executed
post-recovery: drained 2 NMX subscription messages post-recovery: drained 2 NMX subscription messages
``` ```
The replay branch in `recover_connection_core` (`session.rs:1428-...`) re-issues `RegisterReference` (NOT `AdviseSupervisory`) for the buffered entry, mirroring `MxNativeSession.ReAdviseSubscription` (`cs:538-569`). Structural property is unit-tested; this live test confirms the engine actually picks back up after the rebuild + replay. The replay branch in `Session::recover_connection_core` re-issues `RegisterReference` (NOT `AdviseSupervisory`) for the buffered entry, mirroring `MxNativeSession.ReAdviseSubscription` (`cs:538-569`). Structural property is unit-tested; this live test confirms the engine actually picks back up after the rebuild + replay.
## Step 3 — F47 buffered unsubscribe skip (PASS) ## Step 3 — F47 buffered unsubscribe skip (PASS)
@@ -80,7 +82,7 @@ buffered subscribed, correlation_id = [...]
buffered unsubscribe returned Ok — F47 skip path verified live buffered unsubscribe returned Ok — F47 skip path verified live
``` ```
`Session::unsubscribe` (`session.rs:2261`) probes the registry for the subscription's mode; if `Buffered { .. }`, it skips the `nmx.un_advise(...)` wire call entirely. Mirrors the .NET reference's `if (!subscription.IsBuffered)` guard at `MxNativeSession.cs:361-381`. If the implementation accidentally emitted `UnAdvise` for a buffered correlation id, the engine would return non-zero HRESULT (no matching plain advise to retract) — surfacing as a panic in this test. `Session::unsubscribe` probes the registry for the subscription's mode; if `Buffered { .. }`, it skips the `nmx.un_advise(...)` wire call entirely. Mirrors the .NET reference's `if (!subscription.IsBuffered)` guard at `MxNativeSession.cs:361-381`. If the implementation accidentally emitted `UnAdvise` for a buffered correlation id, the engine would return non-zero HRESULT (no matching plain advise to retract) — surfacing as a panic in this test.
## Step 4 — F40 metrics live smoke (PASS) ## Step 4 — F40 metrics live smoke (PASS)
@@ -128,6 +130,8 @@ The `WriteCompleteEvent { server_handle, item_handle, statuses, is_during_recove
## Reproducing locally ## Reproducing locally
### Live tests (require AVEVA + MX_LIVE env)
```powershell ```powershell
# 1. Populate live env from Infisical (dot-source so vars persist). # 1. Populate live env from Infisical (dot-source so vars persist).
. .\tools\Setup-LiveProbeEnv.ps1 . .\tools\Setup-LiveProbeEnv.ps1
@@ -155,6 +159,64 @@ cargo test -p mxaccess-compat --features live-windows-com `
--test buffered_unsubscribe_skip_live -- --ignored --nocapture --test buffered_unsubscribe_skip_live -- --ignored --nocapture
``` ```
### Workspace gate (no live infra needed)
```powershell
cd rust
cargo build --workspace --all-targets
cargo test --workspace --no-fail-fast
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p mxaccess-codec
```
Expected: build clean, 847 tests pass + 9 ignored (live-only), clippy `-D warnings` clean, bench under R12's < 5 allocs/write target. `cargo fmt --all -- --check` flags pre-existing workspace-wide drift unrelated to any session edit (see § "Workspace gate" below).
## Open work ## Open work
- **F50** — residual Frida capture for Suspend/Activate (independent of F49). None. F49 sweep complete; F50 (residual Frida capture for Suspend/Activate) closed 2026-05-06 per `docs/F50-suspend-activate-evidence.md`.
## Workspace gate (2026-05-07)
End-of-session sanity sweep against `master` at commit `9ed4700` plus the F56 unit-test fixture fix that this gate flagged. Run from `rust/` on Windows x64.
| Gate | Command | Result |
|---|---|---|
| Build | `cargo build --workspace --all-targets` | **Pass** (19.81 s) |
| Tests | `cargo test --workspace --no-fail-fast` | **Pass** — 847 passed, 0 failed, 9 ignored (live-only) |
| Clippy | `cargo clippy --workspace --all-targets -- -D warnings` | **Pass** |
| Bench | `cargo bench -p mxaccess-codec` | **Pass** — R12 < 5 allocs/write target met |
The `cargo fmt --all -- --check` gate flags pre-existing workspace-wide rustfmt drift across 29 files (~1000 lines, mostly machine-generated `mxaccess-asb-nettcp/src/nbfs.rs`). Drift is unrelated to any individual session's edits and is documented here as a known workspace-hygiene gap; per-file formatting is applied to edited files at edit time.
### F56 test-fixture bug surfaced + fixed by this gate
The workspace test sweep flagged 9 failing unit tests in `mxaccess::session` that had been silently failing since F56 landed (commit `5e11b30`). Root cause: F56 added `ensure_publisher_connected` (issuing `INmxService2::Connect` + `AddSubscriberEngine` before each `AdviseSupervisory`) but the in-process fake-NMX-server fixtures' `responses` vec sizes weren't bumped to absorb the two new RPCs. Symptom was `ConnectionAborted (10053)` once the fake server's response budget ran out mid-handshake.
Fix: bumped each test's `unauthenticated_server` / `recording_server` response count by 2 to cover Connect + AddSubscriberEngine. Tests touched (all in `crates/mxaccess/src/session.rs::tests`):
- `subscribe_then_unsubscribe_round_trip` (2 → 4 responses)
- `two_subscribes_produce_distinct_correlation_ids` (4 → 6; second subscribe hits the per-engine cache)
- `subscription_stream_yields_data_change_for_matching_correlation` (1 → 3)
- `subscription_stream_filters_out_mismatched_correlation_for_status` (1 → 3)
- `subscription_stream_keeps_data_update_regardless_of_correlation` (1 → 3)
- `subscribe_populates_registry_unsubscribe_clears_it` (2 → 4)
- `read_returns_first_data_change_within_timeout` (2 → 4)
- `read_returns_timeout_when_no_data_arrives` (2 → 4)
- `unsubscribe_skips_un_advise_for_buffered_subscription` (2 → 3 + mid-flow assertion bumped from `len() == 1` to `len() == 3`)
Bench numbers post-fix (release profile, Windows x64):
| scenario | allocs/op |
|---|---|
| `write_message::encode` (Int32) | 2.00 |
| `write_message::encode` (Float32) | 2.00 |
| `write_message::encode` (Float64) | 2.00 |
| `write_message::encode` (Boolean) | 1.00 |
| `write_message::encode` (String, 5 chars) | 4.00 |
| `write_message::encode_to_bytes_mut` (Int32, F52.1) | 2.00 |
| `write_message::encode_into_bytes_mut` (Int32, pooled, F52.3) | 1.00 |
| `write_message::encode_into_bytes_mut` (Boolean, pooled, F52.3) | 0.00 |
| `MxReferenceHandle::from_names` (cache, F52.2) | 0.00 |
| `NmxSubscriptionMessage::parse_inner` (DataUpdate, Int32) | 1.00 |
All numbers match `design/M6-bench-baseline.md` § F52.{1,2,3}.
+1
View File
@@ -693,6 +693,7 @@ dependencies = [
name = "mxaccess-codec" name = "mxaccess-codec"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"bytes",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
bytes = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[features] [features]
@@ -38,8 +38,9 @@
use std::alloc::{GlobalAlloc, Layout, System}; use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use bytes::BytesMut;
use mxaccess_codec::{ use mxaccess_codec::{
MxReferenceHandle, NmxSubscriptionMessage, write_message, write_message::WriteValue, write_message, write_message::WriteValue, MxReferenceHandle, NmxSubscriptionMessage,
}; };
// ---- counting allocator ------------------------------------------------- // ---- counting allocator -------------------------------------------------
@@ -202,6 +203,51 @@ fn bench_write_string() -> Row {
}) })
} }
// F52.1 — `BytesMut` output. Same alloc count as `encode`; the benefit is
// downstream zero-copy (consumers can `split_to` / `freeze` without copying
// the body bytes).
fn bench_write_int32_bytes_mut() -> Row {
let handle = make_handle();
let value = WriteValue::Int32(42);
measure("write_message::encode_to_bytes_mut (Int32)", 10_000, || {
let bytes = write_message::encode_to_bytes_mut(&handle, &value, 0, 0).unwrap();
std::hint::black_box(bytes);
})
}
// F52.3 — session-level scratch buffer. The caller supplies a `BytesMut`
// that is cleared and resized in place, so the body allocation is amortised
// across a session's writes. Drops the per-write count from 2 → 1 for
// fixed-width scalars (the remaining alloc is the per-value scratch buffer
// inside `encode_scalar_value`) and 1 → 0 for Boolean (no scalar scratch).
fn bench_write_int32_into_pooled() -> Row {
let handle = make_handle();
let value = WriteValue::Int32(42);
let mut buf = BytesMut::new();
measure(
"write_message::encode_into_bytes_mut (Int32, pooled)",
10_000,
|| {
write_message::encode_into_bytes_mut(&handle, &value, 0, 0, &mut buf).unwrap();
std::hint::black_box(&buf);
},
)
}
fn bench_write_bool_into_pooled() -> Row {
let handle = make_handle();
let value = WriteValue::Boolean(true);
let mut buf = BytesMut::new();
measure(
"write_message::encode_into_bytes_mut (Boolean, pooled)",
10_000,
|| {
write_message::encode_into_bytes_mut(&handle, &value, 0, 0, &mut buf).unwrap();
std::hint::black_box(&buf);
},
)
}
fn bench_subscription_decode() -> Row { fn bench_subscription_decode() -> Row {
// Build a single-record DataUpdate body once; decode N times. // Build a single-record DataUpdate body once; decode N times.
let body = build_data_update_int32_body(42); let body = build_data_update_int32_body(42);
@@ -262,6 +308,9 @@ fn main() {
bench_write_double(), bench_write_double(),
bench_write_bool(), bench_write_bool(),
bench_write_string(), bench_write_string(),
bench_write_int32_bytes_mut(),
bench_write_int32_into_pooled(),
bench_write_bool_into_pooled(),
bench_handle_from_names(), bench_handle_from_names(),
bench_subscription_decode(), bench_subscription_decode(),
]; ];
@@ -10,6 +10,9 @@
// `.get(n)?` would obscure the byte map. // `.get(n)?` would obscure the byte map.
#![allow(clippy::indexing_slicing)] #![allow(clippy::indexing_slicing)]
use std::cell::RefCell;
use std::collections::HashMap;
use crate::error::CodecError; use crate::error::CodecError;
const CRC16_IBM_POLYNOMIAL: u16 = 0xa001; const CRC16_IBM_POLYNOMIAL: u16 = 0xa001;
@@ -191,6 +194,13 @@ impl MxReferenceHandle {
/// mappings (e.g. Turkish dotless-i) may diverge — see /// mappings (e.g. Turkish dotless-i) may diverge — see
/// `design/10-raw-layer.md` L37 for the path forward via `icu_casemap`. /// `design/10-raw-layer.md` L37 for the path forward via `icu_casemap`.
/// ///
/// **Caching**: Results are memoised in a thread-local
/// [`HashMap`]<[`String`], `u16`> so repeated calls with the same name (the
/// hot path inside [`MxReferenceHandle::from_names`] when the same handles
/// are constructed many times) skip the UTF-16LE conversion and CRC walk.
/// The cache is bounded ([`SIGNATURE_CACHE_CAP`] entries); on overflow the
/// thread's cache is cleared. (F52.2 from `design/M6-bench-baseline.md`.)
///
/// # Errors /// # Errors
/// ///
/// Returns [`CodecError::InvalidName`] if `name` is empty or whitespace-only. /// Returns [`CodecError::InvalidName`] if `name` is empty or whitespace-only.
@@ -198,6 +208,35 @@ pub fn compute_name_signature(name: &str) -> Result<u16, CodecError> {
if name.trim().is_empty() { if name.trim().is_empty() {
return Err(CodecError::InvalidName); return Err(CodecError::InvalidName);
} }
// Fast path: thread-local cache lookup. Repeated calls with the same name
// skip the `to_lowercase` allocation entirely.
if let Some(cached) = SIGNATURE_CACHE.with(|c| c.borrow().get(name).copied()) {
return Ok(cached);
}
let signature = compute_name_signature_uncached(name);
SIGNATURE_CACHE.with(|c| {
let mut cache = c.borrow_mut();
if cache.len() >= SIGNATURE_CACHE_CAP {
cache.clear();
}
cache.insert(name.to_string(), signature);
});
Ok(signature)
}
/// Soft cap on the per-thread name → signature cache. Keeps memory bounded
/// when a workload churns through unique names (e.g. dynamic discovery). On
/// overflow the cache is cleared rather than evicted LRU — any sane workload
/// re-fills only the names it actively uses.
pub const SIGNATURE_CACHE_CAP: usize = 1024;
thread_local! {
static SIGNATURE_CACHE: RefCell<HashMap<String, u16>> = RefCell::new(HashMap::new());
}
fn compute_name_signature_uncached(name: &str) -> u16 {
let lower = name.to_lowercase(); let lower = name.to_lowercase();
let mut crc: u16 = 0; let mut crc: u16 = 0;
for ch in lower.chars() { for ch in lower.chars() {
@@ -212,7 +251,16 @@ pub fn compute_name_signature(name: &str) -> Result<u16, CodecError> {
crc = update_crc16_ibm(crc, (*unit >> 8) as u8); crc = update_crc16_ibm(crc, (*unit >> 8) as u8);
} }
} }
Ok(crc) crc
}
/// Clear the current thread's name → signature cache. Used by tests that
/// want to measure cold-path behaviour; not exposed publicly because the
/// cache is otherwise transparent to callers.
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn clear_signature_cache_for_tests() {
SIGNATURE_CACHE.with(|c| c.borrow_mut().clear());
} }
/// One iteration of the CRC-16/IBM update loop (poly `0xa001`, right-shifted /// One iteration of the CRC-16/IBM update loop (poly `0xa001`, right-shifted
@@ -333,6 +381,34 @@ mod tests {
assert_eq!(update_crc16_ibm(0, 0), 0); assert_eq!(update_crc16_ibm(0, 0), 0);
} }
/// F52.2 — the thread-local cache must return the same value for cold
/// (cache-miss) and hot (cache-hit) calls. Walking the cache twice with
/// the same name should be a no-op as far as the result goes.
#[test]
fn signature_cache_hit_matches_cold_compute() {
clear_signature_cache_for_tests();
let cold = compute_name_signature("TestObject").unwrap();
// Second call should hit the cache.
let hot = compute_name_signature("TestObject").unwrap();
assert_eq!(cold, hot);
// And match the well-known dotnet-parity vector.
assert_eq!(cold, 0x0B25);
}
#[test]
fn signature_cache_overflow_clears() {
clear_signature_cache_for_tests();
// Exceed the cap by one to trigger a clear.
for i in 0..=SIGNATURE_CACHE_CAP {
let name = format!("Tag{i}");
compute_name_signature(&name).unwrap();
}
// After overflow, recompute against a known vector should still
// produce the right value (cache hit-or-miss, doesn't matter — the
// returned u16 is what we assert on).
assert_eq!(compute_name_signature("TestObject").unwrap(), 0x0B25);
}
#[test] #[test]
fn round_trip_zero_handle() { fn round_trip_zero_handle() {
let handle = MxReferenceHandle::default(); let handle = MxReferenceHandle::default();
+378 -83
View File
@@ -88,8 +88,10 @@
// Direct byte indexing — see reference_handle.rs / envelope.rs for rationale. // Direct byte indexing — see reference_handle.rs / envelope.rs for rationale.
#![allow(clippy::indexing_slicing)] #![allow(clippy::indexing_slicing)]
use crate::MxReferenceHandle; use bytes::BytesMut;
use crate::error::CodecError; use crate::error::CodecError;
use crate::MxReferenceHandle;
/// Normal-write opcode (`NmxWriteMessage.cs:9`). /// Normal-write opcode (`NmxWriteMessage.cs:9`).
pub const COMMAND: u8 = 0x37; pub const COMMAND: u8 = 0x37;
@@ -253,6 +255,50 @@ pub fn encode(
encode_inner(handle, value, write_index, client_token, None) encode_inner(handle, value, write_index, client_token, None)
} }
/// Encode a normal write body (`0x37`) into a freshly-allocated [`BytesMut`].
///
/// Equivalent to [`encode`] but returns a `BytesMut` so the caller can
/// `split_to(n)` / `freeze()` and forward to a wire-level sink without an
/// intermediate copy. Allocation count is identical to [`encode`]; the
/// benefit is downstream zero-copy. (F52.1 from `design/M6-bench-baseline.md`.)
///
/// # Errors
///
/// See [`encode`].
pub fn encode_to_bytes_mut(
handle: &MxReferenceHandle,
value: &WriteValue,
write_index: i32,
client_token: u32,
) -> Result<BytesMut, CodecError> {
let mut dst = BytesMut::new();
encode_inner_into(handle, value, write_index, client_token, None, &mut dst)?;
Ok(dst)
}
/// Encode a normal write body (`0x37`) into a caller-supplied [`BytesMut`]
/// scratch buffer. Clears `dst` first, resizes it to fit the body, and fills
/// it via the standard codec path.
///
/// Reusing the same `dst` across writes amortises the body allocation and
/// drops per-write alloc count from 2 → 1 for fixed-width scalars (and 1 → 0
/// for Boolean) once the buffer is sized for the largest body the session
/// will produce. (F52.3 session scratch pool from
/// `design/M6-bench-baseline.md`.)
///
/// # Errors
///
/// See [`encode`].
pub fn encode_into_bytes_mut(
handle: &MxReferenceHandle,
value: &WriteValue,
write_index: i32,
client_token: u32,
dst: &mut BytesMut,
) -> Result<(), CodecError> {
encode_inner_into(handle, value, write_index, client_token, None, dst)
}
/// Encode a `Write2` (timestamped) body. Mirrors `NmxWriteMessage.EncodeTimestamped` /// Encode a `Write2` (timestamped) body. Mirrors `NmxWriteMessage.EncodeTimestamped`
/// (`NmxWriteMessage.cs:36-56`). /// (`NmxWriteMessage.cs:36-56`).
/// ///
@@ -279,6 +325,53 @@ pub fn encode_timestamped(
) )
} }
/// `Write2` (timestamped) variant of [`encode_to_bytes_mut`].
///
/// # Errors
///
/// See [`encode`].
pub fn encode_timestamped_to_bytes_mut(
handle: &MxReferenceHandle,
value: &WriteValue,
timestamp_filetime: i64,
write_index: i32,
client_token: u32,
) -> Result<BytesMut, CodecError> {
let mut dst = BytesMut::new();
encode_inner_into(
handle,
value,
write_index,
client_token,
Some(timestamp_filetime),
&mut dst,
)?;
Ok(dst)
}
/// `Write2` (timestamped) variant of [`encode_into_bytes_mut`].
///
/// # Errors
///
/// See [`encode`].
pub fn encode_timestamped_into_bytes_mut(
handle: &MxReferenceHandle,
value: &WriteValue,
timestamp_filetime: i64,
write_index: i32,
client_token: u32,
dst: &mut BytesMut,
) -> Result<(), CodecError> {
encode_inner_into(
handle,
value,
write_index,
client_token,
Some(timestamp_filetime),
dst,
)
}
fn encode_inner( fn encode_inner(
handle: &MxReferenceHandle, handle: &MxReferenceHandle,
value: &WriteValue, value: &WriteValue,
@@ -286,54 +379,82 @@ fn encode_inner(
client_token: u32, client_token: u32,
timestamp: Option<i64>, timestamp: Option<i64>,
) -> Result<Vec<u8>, CodecError> { ) -> Result<Vec<u8>, CodecError> {
let mut buf = Vec::new();
write_body_into_vec(
handle,
value,
write_index,
client_token,
timestamp,
&mut buf,
)?;
Ok(buf)
}
fn encode_inner_into(
handle: &MxReferenceHandle,
value: &WriteValue,
write_index: i32,
client_token: u32,
timestamp: Option<i64>,
dst: &mut BytesMut,
) -> Result<(), CodecError> {
write_body_into_bytes_mut(handle, value, write_index, client_token, timestamp, dst)
}
/// Resize `dst` (a `Vec<u8>`) to the encoded body size and fill it. Used by
/// the [`encode`] path so the existing `Vec<u8>`-returning surface is one
/// allocation regardless of how the body is built downstream.
fn write_body_into_vec(
handle: &MxReferenceHandle,
value: &WriteValue,
write_index: i32,
client_token: u32,
timestamp: Option<i64>,
dst: &mut Vec<u8>,
) -> Result<(), CodecError> {
let kind = value.kind(); let kind = value.kind();
match value { match value {
WriteValue::Boolean(b) => Ok(encode_boolean( WriteValue::Boolean(b) => {
handle, let size = boolean_body_size(timestamp);
*b, resize_vec(dst, size);
write_index, write_boolean_body(handle, *b, write_index, client_token, timestamp, dst);
client_token, }
timestamp,
)),
WriteValue::Int32(_) | WriteValue::Float32(_) | WriteValue::Float64(_) => { WriteValue::Int32(_) | WriteValue::Float32(_) | WriteValue::Float64(_) => {
let value_bytes = encode_scalar_value(value); let value_bytes = encode_scalar_value(value);
Ok(encode_fixed( let size = fixed_body_size(value_bytes.len());
resize_vec(dst, size);
write_fixed_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
);
} }
WriteValue::String(s) => { WriteValue::String(s) | WriteValue::DateTime(s) => {
let value_bytes = encode_utf16_string(s); let value_bytes = encode_utf16_string(s);
Ok(encode_variable( let size = variable_body_size(value_bytes.len());
resize_vec(dst, size);
write_variable_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
} );
WriteValue::DateTime(s) => {
// Caller pre-formats DateTime (see `WriteValue::DateTime` doc).
let value_bytes = encode_utf16_string(s);
Ok(encode_variable(
handle,
kind,
&value_bytes,
write_index,
client_token,
timestamp,
))
} }
WriteValue::BooleanArray(arr) => { WriteValue::BooleanArray(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?; let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(2); let element_width = kind.array_element_width().unwrap_or(2);
let value_bytes = encode_boolean_array(arr); let value_bytes = encode_boolean_array(arr);
Ok(encode_array( let size = array_body_size(value_bytes.len());
resize_vec(dst, size);
write_array_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
@@ -342,13 +463,16 @@ fn encode_inner(
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
);
} }
WriteValue::Int32Array(arr) => { WriteValue::Int32Array(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?; let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(4); let element_width = kind.array_element_width().unwrap_or(4);
let value_bytes = encode_i32_array(arr); let value_bytes = encode_i32_array(arr);
Ok(encode_array( let size = array_body_size(value_bytes.len());
resize_vec(dst, size);
write_array_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
@@ -357,13 +481,16 @@ fn encode_inner(
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
);
} }
WriteValue::Float32Array(arr) => { WriteValue::Float32Array(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?; let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(4); let element_width = kind.array_element_width().unwrap_or(4);
let value_bytes = encode_f32_array(arr); let value_bytes = encode_f32_array(arr);
Ok(encode_array( let size = array_body_size(value_bytes.len());
resize_vec(dst, size);
write_array_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
@@ -372,13 +499,16 @@ fn encode_inner(
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
);
} }
WriteValue::Float64Array(arr) => { WriteValue::Float64Array(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?; let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(8); let element_width = kind.array_element_width().unwrap_or(8);
let value_bytes = encode_f64_array(arr); let value_bytes = encode_f64_array(arr);
Ok(encode_array( let size = array_body_size(value_bytes.len());
resize_vec(dst, size);
write_array_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
@@ -387,13 +517,16 @@ fn encode_inner(
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
);
} }
WriteValue::StringArray(arr) => { WriteValue::StringArray(arr) | WriteValue::DateTimeArray(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?; let count = value.array_count().ok_or_else(array_too_large)?;
// Variable arrays hard-code element_width = 4 (`NmxWriteMessage.cs:30, 52`). // Variable arrays hard-code element_width = 4 (`NmxWriteMessage.cs:30, 52`).
let value_bytes = encode_variable_array(arr.iter().map(String::as_str)); let value_bytes = encode_variable_array(arr.iter().map(String::as_str));
Ok(encode_array( let size = array_body_size(value_bytes.len());
resize_vec(dst, size);
write_array_body(
handle, handle,
kind, kind,
&value_bytes, &value_bytes,
@@ -402,23 +535,162 @@ fn encode_inner(
write_index, write_index,
client_token, client_token,
timestamp, timestamp,
)) dst,
} );
WriteValue::DateTimeArray(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?;
let value_bytes = encode_variable_array(arr.iter().map(String::as_str));
Ok(encode_array(
handle,
kind,
&value_bytes,
count,
4,
write_index,
client_token,
timestamp,
))
} }
} }
Ok(())
}
/// `BytesMut` mirror of [`write_body_into_vec`]. Same body content; the only
/// difference is the buffer type. Kept as a parallel function rather than
/// generic over a trait to avoid pulling a trait abstraction into the public
/// API surface (`cargo public-api` baseline must stay unchanged for F52
/// per the followup DoD).
fn write_body_into_bytes_mut(
handle: &MxReferenceHandle,
value: &WriteValue,
write_index: i32,
client_token: u32,
timestamp: Option<i64>,
dst: &mut BytesMut,
) -> Result<(), CodecError> {
let kind = value.kind();
match value {
WriteValue::Boolean(b) => {
let size = boolean_body_size(timestamp);
resize_bytes_mut(dst, size);
write_boolean_body(handle, *b, write_index, client_token, timestamp, dst);
}
WriteValue::Int32(_) | WriteValue::Float32(_) | WriteValue::Float64(_) => {
let value_bytes = encode_scalar_value(value);
let size = fixed_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_fixed_body(
handle,
kind,
&value_bytes,
write_index,
client_token,
timestamp,
dst,
);
}
WriteValue::String(s) | WriteValue::DateTime(s) => {
let value_bytes = encode_utf16_string(s);
let size = variable_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_variable_body(
handle,
kind,
&value_bytes,
write_index,
client_token,
timestamp,
dst,
);
}
WriteValue::BooleanArray(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(2);
let value_bytes = encode_boolean_array(arr);
let size = array_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_array_body(
handle,
kind,
&value_bytes,
count,
element_width,
write_index,
client_token,
timestamp,
dst,
);
}
WriteValue::Int32Array(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(4);
let value_bytes = encode_i32_array(arr);
let size = array_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_array_body(
handle,
kind,
&value_bytes,
count,
element_width,
write_index,
client_token,
timestamp,
dst,
);
}
WriteValue::Float32Array(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(4);
let value_bytes = encode_f32_array(arr);
let size = array_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_array_body(
handle,
kind,
&value_bytes,
count,
element_width,
write_index,
client_token,
timestamp,
dst,
);
}
WriteValue::Float64Array(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?;
let element_width = kind.array_element_width().unwrap_or(8);
let value_bytes = encode_f64_array(arr);
let size = array_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_array_body(
handle,
kind,
&value_bytes,
count,
element_width,
write_index,
client_token,
timestamp,
dst,
);
}
WriteValue::StringArray(arr) | WriteValue::DateTimeArray(arr) => {
let count = value.array_count().ok_or_else(array_too_large)?;
let value_bytes = encode_variable_array(arr.iter().map(String::as_str));
let size = array_body_size(value_bytes.len());
resize_bytes_mut(dst, size);
write_array_body(
handle,
kind,
&value_bytes,
count,
4,
write_index,
client_token,
timestamp,
dst,
);
}
}
Ok(())
}
fn resize_vec(dst: &mut Vec<u8>, size: usize) {
dst.clear();
dst.resize(size, 0);
}
fn resize_bytes_mut(dst: &mut BytesMut, size: usize) {
dst.clear();
dst.resize(size, 0);
} }
fn array_too_large() -> CodecError { fn array_too_large() -> CodecError {
@@ -431,21 +703,53 @@ fn array_too_large() -> CodecError {
// ---- Body builders -------------------------------------------------------- // ---- Body builders --------------------------------------------------------
// All builders below assume `body` is a pre-sized, zero-initialised slice
// (the dispatcher resizes the destination buffer up front). They are
// allocation-free; the only allocations on the encode path are (a) the
// destination buffer itself and (b) the per-value scratch buffer (e.g.
// `encode_scalar_value`). Pulling the size compute out of the builders
// is what lets F52.3 reuse the destination buffer across writes.
const fn boolean_body_size(timestamp: Option<i64>) -> usize {
if timestamp.is_some() {
// Timestamped: 1-byte payload + 14-byte timestamped suffix + 4-byte index.
KIND_OFFSET + 1 + 1 + 14 + 4
} else {
// Normal: 4-byte literal payload + 11-byte Boolean suffix + 4-byte index.
// Total = 18 + 4 + 11 + 4 = 37 bytes (`NmxWriteMessage.cs:123`).
KIND_OFFSET + 1 + 4 + 11 + 4
}
}
const fn fixed_body_size(value_bytes_len: usize) -> usize {
KIND_OFFSET + 1 + value_bytes_len + 14 + 4
}
const fn variable_body_size(value_bytes_len: usize) -> usize {
// body alloc = 18 + 4 + 4 + N + 14 + 4 = 44 + N.
KIND_OFFSET + 1 + 4 + 4 + value_bytes_len + 14 + 4
}
const fn array_body_size(value_bytes_len: usize) -> usize {
// body alloc = 18 + 10 + N + 14 + 4 (`NmxWriteMessage.cs:179, 198`).
KIND_OFFSET + 1 + 10 + value_bytes_len + 14 + 4
}
/// Boolean write body. The normal form uses the 11-byte Boolean suffix /// Boolean write body. The normal form uses the 11-byte Boolean suffix
/// (`NmxWriteMessage.cs:121-128`); the timestamped form uses a single-byte /// (`NmxWriteMessage.cs:121-128`); the timestamped form uses a single-byte
/// payload with the 14-byte timestamped suffix (`NmxWriteMessage.cs:130-137`). /// payload with the 14-byte timestamped suffix (`NmxWriteMessage.cs:130-137`).
fn encode_boolean( fn write_boolean_body(
handle: &MxReferenceHandle, handle: &MxReferenceHandle,
value: bool, value: bool,
write_index: i32, write_index: i32,
client_token: u32, client_token: u32,
timestamp: Option<i64>, timestamp: Option<i64>,
) -> Vec<u8> { body: &mut [u8],
) {
if let Some(filetime) = timestamp { if let Some(filetime) = timestamp {
// Timestamped: 1-byte payload + 14-byte timestamped suffix + 4-byte index. // Timestamped: 1-byte payload + 14-byte timestamped suffix + 4-byte index.
// Total = 18 + 1 + 14 + 4 = 37. Same total as normal Boolean. // Total = 18 + 1 + 14 + 4 = 37. Same total as normal Boolean.
let mut body = vec![0u8; KIND_OFFSET + 1 + 1 + 14 + 4]; write_common_prefix(body, handle, WriteValueKind::Boolean);
write_common_prefix(&mut body, handle, WriteValueKind::Boolean);
body[KIND_OFFSET + 1] = if value { 0xff } else { 0x00 }; body[KIND_OFFSET + 1] = if value { 0xff } else { 0x00 };
write_timestamped_suffix( write_timestamped_suffix(
&mut body[KIND_OFFSET + 2..], &mut body[KIND_OFFSET + 2..],
@@ -453,35 +757,31 @@ fn encode_boolean(
write_index, write_index,
client_token, client_token,
); );
body
} else { } else {
// Normal: 4-byte literal payload + 11-byte Boolean suffix + 4-byte index. // Normal: 4-byte literal payload + 11-byte Boolean suffix + 4-byte index.
// Total = 18 + 4 + 11 + 4 = 37 bytes (`NmxWriteMessage.cs:123`).
let value_bytes = encode_boolean_value(value); let value_bytes = encode_boolean_value(value);
let mut body = vec![0u8; KIND_OFFSET + 1 + value_bytes.len() + 11 + 4]; write_common_prefix(body, handle, WriteValueKind::Boolean);
write_common_prefix(&mut body, handle, WriteValueKind::Boolean);
body[KIND_OFFSET + 1..KIND_OFFSET + 1 + value_bytes.len()].copy_from_slice(&value_bytes); body[KIND_OFFSET + 1..KIND_OFFSET + 1 + value_bytes.len()].copy_from_slice(&value_bytes);
write_boolean_suffix( write_boolean_suffix(
&mut body[KIND_OFFSET + 1 + value_bytes.len()..], &mut body[KIND_OFFSET + 1 + value_bytes.len()..],
write_index, write_index,
client_token, client_token,
); );
body
} }
} }
/// Fixed-size scalar (Int32, Float32, Float64). Mirrors `CreateFixed` / /// Fixed-size scalar (Int32, Float32, Float64). Mirrors `CreateFixed` /
/// `CreateFixedTimestamped` (`NmxWriteMessage.cs:112-119, 139-146`). /// `CreateFixedTimestamped` (`NmxWriteMessage.cs:112-119, 139-146`).
fn encode_fixed( fn write_fixed_body(
handle: &MxReferenceHandle, handle: &MxReferenceHandle,
kind: WriteValueKind, kind: WriteValueKind,
value_bytes: &[u8], value_bytes: &[u8],
write_index: i32, write_index: i32,
client_token: u32, client_token: u32,
timestamp: Option<i64>, timestamp: Option<i64>,
) -> Vec<u8> { body: &mut [u8],
let mut body = vec![0u8; KIND_OFFSET + 1 + value_bytes.len() + 14 + 4]; ) {
write_common_prefix(&mut body, handle, kind); write_common_prefix(body, handle, kind);
body[KIND_OFFSET + 1..KIND_OFFSET + 1 + value_bytes.len()].copy_from_slice(value_bytes); body[KIND_OFFSET + 1..KIND_OFFSET + 1 + value_bytes.len()].copy_from_slice(value_bytes);
let suffix_start = KIND_OFFSET + 1 + value_bytes.len(); let suffix_start = KIND_OFFSET + 1 + value_bytes.len();
match timestamp { match timestamp {
@@ -490,28 +790,26 @@ fn encode_fixed(
} }
None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token), None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token),
} }
body
} }
/// Variable-length payload (String, DateTime). Mirrors `CreateVariable` / /// Variable-length payload (String, DateTime). Mirrors `CreateVariable` /
/// `CreateVariableTimestamped` (`NmxWriteMessage.cs:148-168`). Total length /// `CreateVariableTimestamped` (`NmxWriteMessage.cs:148-168`). Total length
/// is `44 + utf16_bytes_len`. /// is `44 + utf16_bytes_len`.
fn encode_variable( fn write_variable_body(
handle: &MxReferenceHandle, handle: &MxReferenceHandle,
kind: WriteValueKind, kind: WriteValueKind,
value_bytes: &[u8], value_bytes: &[u8],
write_index: i32, write_index: i32,
client_token: u32, client_token: u32,
timestamp: Option<i64>, timestamp: Option<i64>,
) -> Vec<u8> { body: &mut [u8],
// body alloc = 18 + 4 + 4 + N + 14 + 4 = 44 + N. ) {
let mut body = vec![0u8; KIND_OFFSET + 1 + 4 + 4 + value_bytes.len() + 14 + 4]; write_common_prefix(body, handle, kind);
write_common_prefix(&mut body, handle, kind);
// body[18..22] = outer_length = N + 4 (`NmxWriteMessage.cs:152, 163`) // body[18..22] = outer_length = N + 4 (`NmxWriteMessage.cs:152, 163`)
let outer_len = (value_bytes.len() as i32).wrapping_add(4); let outer_len = (value_bytes.len() as i32).wrapping_add(4);
write_i32_le(&mut body, 18, outer_len); write_i32_le(body, 18, outer_len);
// body[22..26] = inner_length = N (`NmxWriteMessage.cs:153, 164`) // body[22..26] = inner_length = N (`NmxWriteMessage.cs:153, 164`)
write_i32_le(&mut body, 22, value_bytes.len() as i32); write_i32_le(body, 22, value_bytes.len() as i32);
// body[26..26+N] = payload (`NmxWriteMessage.cs:154, 165`) // body[26..26+N] = payload (`NmxWriteMessage.cs:154, 165`)
body[26..26 + value_bytes.len()].copy_from_slice(value_bytes); body[26..26 + value_bytes.len()].copy_from_slice(value_bytes);
let suffix_start = 26 + value_bytes.len(); let suffix_start = 26 + value_bytes.len();
@@ -521,13 +819,12 @@ fn encode_variable(
} }
None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token), None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token),
} }
body
} }
/// Array body. Mirrors `CreateArray` / `CreateArrayTimestamped` /// Array body. Mirrors `CreateArray` / `CreateArrayTimestamped`
/// (`NmxWriteMessage.cs:170-205`). /// (`NmxWriteMessage.cs:170-205`).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn encode_array( fn write_array_body(
handle: &MxReferenceHandle, handle: &MxReferenceHandle,
kind: WriteValueKind, kind: WriteValueKind,
value_bytes: &[u8], value_bytes: &[u8],
@@ -536,16 +833,15 @@ fn encode_array(
write_index: i32, write_index: i32,
client_token: u32, client_token: u32,
timestamp: Option<i64>, timestamp: Option<i64>,
) -> Vec<u8> { body: &mut [u8],
// body alloc = 18 + 10 + N + 14 + 4 (`NmxWriteMessage.cs:179, 198`). ) {
let mut body = vec![0u8; KIND_OFFSET + 1 + 10 + value_bytes.len() + 14 + 4]; write_common_prefix(body, handle, kind);
write_common_prefix(&mut body, handle, kind);
// body[22..24] = count u16 LE (`NmxWriteMessage.cs:181, 200`). // body[22..24] = count u16 LE (`NmxWriteMessage.cs:181, 200`).
write_u16_le(&mut body, 22, count); write_u16_le(body, 22, count);
// body[24..26] = element_width u16 LE (`NmxWriteMessage.cs:182, 201`). // body[24..26] = element_width u16 LE (`NmxWriteMessage.cs:182, 201`).
write_u16_le(&mut body, 24, element_width); write_u16_le(body, 24, element_width);
// body[18..22] and body[26..28] are zero-initialised by vec! and not // body[18..22] and body[26..28] are zero-initialised by the dispatcher's
// written by the .NET reference either — they remain zero. // resize and not written by the .NET reference either — they remain zero.
body[28..28 + value_bytes.len()].copy_from_slice(value_bytes); body[28..28 + value_bytes.len()].copy_from_slice(value_bytes);
let suffix_start = 28 + value_bytes.len(); let suffix_start = 28 + value_bytes.len();
match timestamp { match timestamp {
@@ -554,7 +850,6 @@ fn encode_array(
} }
None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token), None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token),
} }
body
} }
// ---- Prefix and suffix writers -------------------------------------------- // ---- Prefix and suffix writers --------------------------------------------
@@ -1578,7 +1873,7 @@ mod tests {
expected.extend_from_slice(&[0x01, 0x00]); // .cs:210 (version=1) expected.extend_from_slice(&[0x01, 0x00]); // .cs:210 (version=1)
expected.extend_from_slice(&projection); // .cs:211 expected.extend_from_slice(&projection); // .cs:211
expected.push(0x01); // .cs:98 Boolean wire kind expected.push(0x01); // .cs:98 Boolean wire kind
// Boolean payload literal (.cs:257) // Boolean payload literal (.cs:257)
expected.extend_from_slice(&[0xff, 0xff, 0xff, 0x00]); expected.extend_from_slice(&[0xff, 0xff, 0xff, 0x00]);
// 7-byte zero region of Boolean suffix (.cs:235) // 7-byte zero region of Boolean suffix (.cs:235)
expected.extend_from_slice(&[0; 7]); expected.extend_from_slice(&[0; 7]);
+69 -22
View File
@@ -2952,8 +2952,16 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn subscribe_then_unsubscribe_round_trip() { async fn subscribe_then_unsubscribe_round_trip() {
// Two RPCs: AdviseSupervisory + UnAdvise. Both return HRESULT 0. // Four RPCs: Connect + AddSubscriberEngine (F56's
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await; // ensure_publisher_connected) + AdviseSupervisory + UnAdvise.
// All return HRESULT 0.
let (addr, handle) = unauthenticated_server(vec![
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
])
.await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -3004,12 +3012,15 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn two_subscribes_produce_distinct_correlation_ids() { async fn two_subscribes_produce_distinct_correlation_ids() {
// Two AdviseSupervisory calls + two UnAdvise calls. // Six RPCs: Connect + AddSubscriberEngine (once, cached on the
// 2nd subscribe) + 2 AdviseSupervisory + 2 UnAdvise.
let (addr, handle) = unauthenticated_server(vec![ let (addr, handle) = unauthenticated_server(vec![
(0, Vec::new()), (0, Vec::new()),
(0, Vec::new()), (0, Vec::new()),
(0, Vec::new()), (0, Vec::new()),
(0, Vec::new()), (0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
]) ])
.await; .await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
@@ -3242,7 +3253,9 @@ mod tests {
async fn subscription_stream_yields_data_change_for_matching_correlation() { async fn subscription_stream_yields_data_change_for_matching_correlation() {
use futures_util::StreamExt; use futures_util::StreamExt;
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new())]).await; // Three RPCs: Connect + AddSubscriberEngine (F56) + AdviseSupervisory.
let (addr, handle) =
unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new()), (0, Vec::new())]).await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -3287,7 +3300,9 @@ mod tests {
async fn subscription_stream_filters_out_mismatched_correlation_for_status() { async fn subscription_stream_filters_out_mismatched_correlation_for_status() {
use futures_util::StreamExt; use futures_util::StreamExt;
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new())]).await; // Three RPCs: Connect + AddSubscriberEngine (F56) + AdviseSupervisory.
let (addr, handle) =
unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new()), (0, Vec::new())]).await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -3322,8 +3337,9 @@ mod tests {
use futures_util::StreamExt; use futures_util::StreamExt;
// 0x33 DataUpdate has no item_correlation_id; the .NET-style // 0x33 DataUpdate has no item_correlation_id; the .NET-style
// filter passes them through to all subscriptions. // filter passes them through to all subscriptions.
// Three RPCs: Connect + AddSubscriberEngine (F56) + AdviseSupervisory.
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new())]).await; let (addr, handle) =
unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new()), (0, Vec::new())]).await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -3472,7 +3488,15 @@ mod tests {
// F16: every successful subscribe() inserts into the // F16: every successful subscribe() inserts into the
// SubscriptionEntry registry; unsubscribe() removes it. // SubscriptionEntry registry; unsubscribe() removes it.
// Recovery walks this registry to replay AdviseSupervisory. // Recovery walks this registry to replay AdviseSupervisory.
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await; // Four RPCs: Connect + AddSubscriberEngine (F56) +
// AdviseSupervisory + UnAdvise.
let (addr, handle) = unauthenticated_server(vec![
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
])
.await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -3802,8 +3826,15 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn read_returns_first_data_change_within_timeout() { async fn read_returns_first_data_change_within_timeout() {
// Server: AdviseSupervisory ack + UnAdvise ack. // Server: Connect + AddSubscriberEngine (F56) +
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await; // AdviseSupervisory + UnAdvise.
let (addr, handle) = unauthenticated_server(vec![
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
])
.await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -3851,9 +3882,16 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn read_returns_timeout_when_no_data_arrives() { async fn read_returns_timeout_when_no_data_arrives() {
// Server only handles the AdviseSupervisory + UnAdvise (no data // Server only handles the Connect + AddSubscriberEngine (F56) +
// injection). Read must hit the timeout branch. // AdviseSupervisory + UnAdvise (no data injection). Read must
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await; // hit the timeout branch.
let (addr, handle) = unauthenticated_server(vec![
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
])
.await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
@@ -4407,21 +4445,29 @@ mod tests {
/// the negative control; this test pins the buffered branch. /// the negative control; this test pins the buffered branch.
#[tokio::test] #[tokio::test]
async fn unsubscribe_skips_un_advise_for_buffered_subscription() { async fn unsubscribe_skips_un_advise_for_buffered_subscription() {
let (addr, recorded, handle) = // Three RPCs: Connect + AddSubscriberEngine (F56) +
recording_server(vec![(0, Vec::new()), (0, Vec::new())]).await; // AdviseSupervisory. The buffered unsubscribe MUST NOT add a
// fourth (F47 skips UnAdvise on buffered drop).
let (addr, recorded, handle) = recording_server(vec![
(0, Vec::new()),
(0, Vec::new()),
(0, Vec::new()),
])
.await;
let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[( let resolver: Arc<dyn Resolver> = Arc::new(StaticResolver::new(&[(
"TestObj.TestInt", "TestObj.TestInt",
sample_metadata(), sample_metadata(),
)])); )]));
let session = connect_test_session(addr, resolver).await.unwrap(); let session = connect_test_session(addr, resolver).await.unwrap();
// Issue a plain subscribe — server records AdviseSupervisory. // Issue a plain subscribe — server records Connect +
// AddSubscriberEngine + AdviseSupervisory.
let sub = session.subscribe("TestObj.TestInt").await.unwrap(); let sub = session.subscribe("TestObj.TestInt").await.unwrap();
let cid = sub.correlation_id; let cid = sub.correlation_id;
assert_eq!( assert_eq!(
recorded.lock().unwrap().len(), recorded.lock().unwrap().len(),
1, 3,
"subscribe should issue 1 RPC" "subscribe should issue 3 RPCs (Connect + AddSubscriberEngine + AdviseSupervisory)"
); );
// Mutate the registry entry's mode to Buffered (synthesise the // Mutate the registry entry's mode to Buffered (synthesise the
@@ -4438,13 +4484,14 @@ mod tests {
} }
// Unsubscribe the now-buffered entry. F47 contract: NO // Unsubscribe the now-buffered entry. F47 contract: NO
// UnAdvise is emitted on the wire; recorded count stays at 1. // UnAdvise is emitted on the wire; recorded count stays at 3
// (the Connect + AddSubscriberEngine + AdviseSupervisory from
// the original subscribe).
session.unsubscribe(sub).await.unwrap(); session.unsubscribe(sub).await.unwrap();
assert_eq!( assert_eq!(
recorded.lock().unwrap().len(), recorded.lock().unwrap().len(),
1, 3,
"buffered unsubscribe must not issue UnAdvise; recorded RPC count must stay at 1 \ "buffered unsubscribe must not issue UnAdvise; recorded RPC count must stay at 3"
(the original AdviseSupervisory)"
); );
// Registry is still cleared — F47's skip applies only to the // Registry is still cleared — F47's skip applies only to the
+15 -4
View File
@@ -67,12 +67,23 @@ function Set-LiveEnvVar {
function Get-InfisicalSecret { function Get-InfisicalSecret {
param([string]$Key, [string]$Env = 'infrastructure', [string]$Path = '/windows-hosts') param([string]$Key, [string]$Env = 'infrastructure', [string]$Path = '/windows-hosts')
# Capture stdout only — the infisical CLI writes its
# "A new release of infisical is available" upgrade banner (and any
# transient diagnostic noise) to STDERR. We deliberately do NOT use
# `2>&1` here so that banner stays in the error stream (visible on
# the console for diagnostics) and never pollutes the secret value.
# An earlier version of this function used `2>&1` plus a regex-based
# banner filter; that approach was brittle to future banner shapes,
# so it was replaced with stream separation. If a real error needs
# to be surfaced, $LASTEXITCODE catches it below.
try { try {
$value = & $GetSecret -Key $Key -Env $Env -Path $Path 2>&1 $value = & $GetSecret -Key $Key -Env $Env -Path $Path
if ($LASTEXITCODE -ne 0 -or -not $value) { if ($LASTEXITCODE -ne 0) {
throw "Get-Secret returned empty for $Env$Path/$Key (exit code $LASTEXITCODE)" throw "Get-Secret exit code $LASTEXITCODE for $Env$Path/$Key"
}
if (-not $value) {
throw "Get-Secret returned empty stdout for $Env$Path/$Key"
} }
# Trim any trailing whitespace from the CLI output
return ($value | Out-String).Trim() return ($value | Out-String).Trim()
} catch { } catch {
throw "Failed to fetch $Env$Path/$Key from Infisical: $_" throw "Failed to fetch $Env$Path/$Key from Infisical: $_"