Compare commits
11 Commits
c7505f9570
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f07da2e12 | |||
| 047125bc11 | |||
| d668d5b7b1 | |||
| 9ed4700eb4 | |||
| 8b50c0fd43 | |||
| cc99a2d9f0 | |||
| ddebab2c2d | |||
| 73e2bd8771 | |||
| ceeaeefa71 | |||
| a0fa5bedfd | |||
| 4e76b44391 |
+82
-20
@@ -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
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -29,6 +31,10 @@ V1 is the first publishable cut. Closes M0 → M6 from
|
||||
`IRemUnknown::RemQueryInterface` + `RemAddRef`/`RemRelease` (F11).
|
||||
- **`mxaccess-callback`** — RPC server hosting `INmxSvcCallback` +
|
||||
`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`,
|
||||
`TransferData`, `AddSubscriberEngine`, `SetHeartbeatSendInterval`,
|
||||
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
|
||||
semantics (F36), `recover_connection` reconnect loop (F16), recovery
|
||||
events (`RecoveryEvent::Started/Recovered/Failed`), and a typed
|
||||
`Error` taxonomy. Optional `metrics` feature emits per-op counters,
|
||||
latency histograms, and connection-state gauges (F40).
|
||||
`Error` taxonomy. Recovery replay re-issues `RegisterReference` (not
|
||||
`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
|
||||
the 18-method `ILMXProxyServer5` surface as async fns over
|
||||
`mxaccess::Session` / `AsbSession` with a `Mutex<HashMap<i32,
|
||||
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`,
|
||||
`subscribe-buffered.rs`, `asb-subscribe.rs`, `multi-tag.rs`,
|
||||
`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
|
||||
`design/public-api/{crate}.txt` with CI drift check (F41).
|
||||
`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)
|
||||
|
||||
@@ -77,26 +130,35 @@ V1 is the first publishable cut. Closes M0 → M6 from
|
||||
### Known limitations
|
||||
|
||||
- **F3** — cross-domain NTLM Type1/2/3 fixture is permanently
|
||||
out-of-scope on the dev host (single-domain). Single-domain wire
|
||||
parity is verified; cross-domain is documented but not regression-
|
||||
tested.
|
||||
- **F45** — recovery replay for buffered subscriptions falls through
|
||||
to plain `AdviseSupervisory`, losing the `.property(buffer)`
|
||||
registration. Filed as a follow-up.
|
||||
- **F46** — `LmxProxy.dll!CLMXProxyServer.Suspend`/`.Activate` wire
|
||||
emission was not instrumented; the compatibility-server's
|
||||
client-side gating is documented but the underlying ORPC call
|
||||
shape is unconfirmed.
|
||||
- **R3 / R4** — `OperationComplete` trigger conditions and
|
||||
completion-only byte mappings are unmapped in both the .NET
|
||||
reference and the Rust port. Frame bytes are preserved verbatim
|
||||
via `Session::operation_status_events()`.
|
||||
out-of-scope on the dev host (single-domain only). Single-domain
|
||||
wire parity is verified; cross-domain rounds-trip through the same
|
||||
shape-agnostic AV-pair codec but no live fixture pins it. Self-
|
||||
contained provisioning recipe (lab topology, capture procedure,
|
||||
fixture layout, round-trip test skeleton) at
|
||||
`docs/F3-cross-domain-ntlm-recipe.md` for whoever has access to a
|
||||
two-forest Windows lab.
|
||||
- **F53 (protocol crates only)** — `#![warn(missing_docs)]` is
|
||||
enabled and warning-clean on the consumer-facing `mxaccess` +
|
||||
`mxaccess-compat` lib roots. Protocol crates measure 1883
|
||||
missing-docs warnings (mostly struct-field-level wire-shape
|
||||
records); enabling the lint there would add per-field one-liners
|
||||
without consumer value. Lint stays off on protocol crates
|
||||
indefinitely. Per-module `#![allow(missing_docs)]` opt-out is the
|
||||
re-introduction path if a contributor wants per-crate enforcement.
|
||||
|
||||
## 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
|
||||
already-published deps to exist on crates.io, so the order matters.
|
||||
For V1 cut:
|
||||
already-published deps to exist on crates.io, so the order matters:
|
||||
|
||||
1. `mxaccess-codec` (no internal deps)
|
||||
2. `mxaccess-rpc` (no internal deps)
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
|
||||
+80
-10
@@ -15,16 +15,19 @@ The bench gates on this: any `write_message::encode` scenario at
|
||||
|
||||
## Baseline (release profile, Windows x64)
|
||||
|
||||
| scenario | iters | allocs/op | bytes/op | deallocs/op |
|
||||
|-------------------------------------------|--------:|----------:|---------:|------------:|
|
||||
| `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` (Float64) | 10,000 | 2.00 | 52 | 2.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 |
|
||||
| `MxReferenceHandle::from_names` | 10,000 | 2.00 | 22 | 2.00 |
|
||||
| `NmxSubscriptionMessage::parse_inner` | 10,000 | 1.00 | 72 | 1.00 |
|
||||
| (DataUpdate, Int32) | | | | |
|
||||
| scenario | iters | allocs/op | bytes/op | deallocs/op |
|
||||
|------------------------------------------------|--------:|----------:|---------:|------------:|
|
||||
| `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` (Float64) | 10,000 | 2.00 | 52 | 2.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_to_bytes_mut` (Int32) | 10,000 | 2.00 | 44 | 2.00 |
|
||||
| `encode_into_bytes_mut` (Int32, pooled, F52.3) | 10,000 | 1.00 | 4 | 1.00 |
|
||||
| `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
|
||||
|
||||
@@ -56,6 +59,73 @@ With the target already met, F39's scope tightens to:
|
||||
|
||||
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
|
||||
|
||||
```powershell
|
||||
|
||||
+15
-52
@@ -6,6 +6,8 @@ move to `## Resolved` with a date + commit hash.
|
||||
|
||||
## 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
|
||||
**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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
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.
|
||||
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. ✅ 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. ✅ 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:**
|
||||
1. Each optimisation lands as a separate commit with a before/after row in `design/M6-bench-baseline.md` showing the alloc-count delta.
|
||||
2. No correctness regressions in the round-trip fixture suite.
|
||||
3. Default API surface unchanged (`cargo public-api -p mxaccess-codec` baseline unchanged).
|
||||
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. (267 tests pass)
|
||||
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
|
||||
**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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Real codec fixes ALSO landed in this session as part of F56 investigation (independent from the resolution above):
|
||||
- `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):**
|
||||
**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 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++`).
|
||||
**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):**
|
||||
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.
|
||||
**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.
|
||||
|
||||
### 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.
|
||||
@@ -216,7 +179,7 @@ This makes Path A the architecturally correct fix: the callback exporter must be
|
||||
**Severity:** P2
|
||||
**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.
|
||||
**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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -95,12 +95,7 @@ throws `ArgumentException("Suspend requires an advised item handle")`).
|
||||
Consequently no `0x32`/`0x33` frame in 077's TCP capture corresponds to
|
||||
the suspend; the capture has nothing to falsify.
|
||||
|
||||
**R5 boundary that is still unproven.** 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`.
|
||||
A follow-up capture with that hook installed would close the residual gap;
|
||||
filed as **F45** below.
|
||||
**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".
|
||||
|
||||
## 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
|
||||
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
|
||||
instrument `LmxProxy.dll!CLMXProxyServer.Suspend` / `.Activate`, so we cannot
|
||||
say whether the production stack issues a dedicated ORPC opnum for these
|
||||
operations or also synthesises them client-side. The R5 trigger conditions
|
||||
documented above ("subscription must exist") are derived from the
|
||||
.NET-reference compatibility server, not from a captured wire frame. Filed
|
||||
as F45 in `design/followups.md` to instrument those entrypoints in the next
|
||||
capture wave.
|
||||
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.
|
||||
|
||||
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`:
|
||||
|
||||
- **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).
|
||||
- **Activate** against a non-suspended item is client-side only — no wire traffic, returns Success synchronously.
|
||||
|
||||
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
|
||||
|
||||
@@ -293,11 +288,4 @@ capture wave.
|
||||
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
|
||||
change landed under F44".
|
||||
- **R5 trigger conditions — observed.** 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? — is filed under F45 with a Frida
|
||||
hook plan.
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|
|
||||
@@ -64,7 +66,7 @@ recover_connection returned Ok — F45 buffered replay path executed
|
||||
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)
|
||||
|
||||
@@ -80,7 +82,7 @@ buffered subscribed, correlation_id = [...]
|
||||
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)
|
||||
|
||||
@@ -128,6 +130,8 @@ The `WriteCompleteEvent { server_handle, item_handle, statuses, is_during_recove
|
||||
|
||||
## Reproducing locally
|
||||
|
||||
### Live tests (require AVEVA + MX_LIVE env)
|
||||
|
||||
```powershell
|
||||
# 1. Populate live env from Infisical (dot-source so vars persist).
|
||||
. .\tools\Setup-LiveProbeEnv.ps1
|
||||
@@ -155,6 +159,64 @@ cargo test -p mxaccess-compat --features live-windows-com `
|
||||
--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
|
||||
|
||||
- **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}.
|
||||
|
||||
Generated
+1
@@ -693,6 +693,7 @@ dependencies = [
|
||||
name = "mxaccess-codec"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -38,8 +38,9 @@
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use bytes::BytesMut;
|
||||
use mxaccess_codec::{
|
||||
MxReferenceHandle, NmxSubscriptionMessage, write_message, write_message::WriteValue,
|
||||
write_message, write_message::WriteValue, MxReferenceHandle, NmxSubscriptionMessage,
|
||||
};
|
||||
|
||||
// ---- 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 {
|
||||
// Build a single-record DataUpdate body once; decode N times.
|
||||
let body = build_data_update_int32_body(42);
|
||||
@@ -262,6 +308,9 @@ fn main() {
|
||||
bench_write_double(),
|
||||
bench_write_bool(),
|
||||
bench_write_string(),
|
||||
bench_write_int32_bytes_mut(),
|
||||
bench_write_int32_into_pooled(),
|
||||
bench_write_bool_into_pooled(),
|
||||
bench_handle_from_names(),
|
||||
bench_subscription_decode(),
|
||||
];
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
// `.get(n)?` would obscure the byte map.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::CodecError;
|
||||
|
||||
const CRC16_IBM_POLYNOMIAL: u16 = 0xa001;
|
||||
@@ -191,6 +194,13 @@ impl MxReferenceHandle {
|
||||
/// mappings (e.g. Turkish dotless-i) may diverge — see
|
||||
/// `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
|
||||
///
|
||||
/// 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() {
|
||||
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 mut crc: u16 = 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -333,6 +381,34 @@ mod tests {
|
||||
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]
|
||||
fn round_trip_zero_handle() {
|
||||
let handle = MxReferenceHandle::default();
|
||||
|
||||
@@ -88,8 +88,10 @@
|
||||
// Direct byte indexing — see reference_handle.rs / envelope.rs for rationale.
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::MxReferenceHandle;
|
||||
use bytes::BytesMut;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::MxReferenceHandle;
|
||||
|
||||
/// Normal-write opcode (`NmxWriteMessage.cs:9`).
|
||||
pub const COMMAND: u8 = 0x37;
|
||||
@@ -253,6 +255,50 @@ pub fn encode(
|
||||
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`
|
||||
/// (`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(
|
||||
handle: &MxReferenceHandle,
|
||||
value: &WriteValue,
|
||||
@@ -286,54 +379,82 @@ fn encode_inner(
|
||||
client_token: u32,
|
||||
timestamp: Option<i64>,
|
||||
) -> 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();
|
||||
match value {
|
||||
WriteValue::Boolean(b) => Ok(encode_boolean(
|
||||
handle,
|
||||
*b,
|
||||
write_index,
|
||||
client_token,
|
||||
timestamp,
|
||||
)),
|
||||
WriteValue::Boolean(b) => {
|
||||
let size = boolean_body_size(timestamp);
|
||||
resize_vec(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);
|
||||
Ok(encode_fixed(
|
||||
let size = fixed_body_size(value_bytes.len());
|
||||
resize_vec(dst, size);
|
||||
write_fixed_body(
|
||||
handle,
|
||||
kind,
|
||||
&value_bytes,
|
||||
write_index,
|
||||
client_token,
|
||||
timestamp,
|
||||
))
|
||||
dst,
|
||||
);
|
||||
}
|
||||
WriteValue::String(s) => {
|
||||
WriteValue::String(s) | WriteValue::DateTime(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,
|
||||
kind,
|
||||
&value_bytes,
|
||||
write_index,
|
||||
client_token,
|
||||
timestamp,
|
||||
))
|
||||
}
|
||||
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,
|
||||
))
|
||||
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);
|
||||
Ok(encode_array(
|
||||
let size = array_body_size(value_bytes.len());
|
||||
resize_vec(dst, size);
|
||||
write_array_body(
|
||||
handle,
|
||||
kind,
|
||||
&value_bytes,
|
||||
@@ -342,13 +463,16 @@ fn encode_inner(
|
||||
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);
|
||||
Ok(encode_array(
|
||||
let size = array_body_size(value_bytes.len());
|
||||
resize_vec(dst, size);
|
||||
write_array_body(
|
||||
handle,
|
||||
kind,
|
||||
&value_bytes,
|
||||
@@ -357,13 +481,16 @@ fn encode_inner(
|
||||
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);
|
||||
Ok(encode_array(
|
||||
let size = array_body_size(value_bytes.len());
|
||||
resize_vec(dst, size);
|
||||
write_array_body(
|
||||
handle,
|
||||
kind,
|
||||
&value_bytes,
|
||||
@@ -372,13 +499,16 @@ fn encode_inner(
|
||||
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);
|
||||
Ok(encode_array(
|
||||
let size = array_body_size(value_bytes.len());
|
||||
resize_vec(dst, size);
|
||||
write_array_body(
|
||||
handle,
|
||||
kind,
|
||||
&value_bytes,
|
||||
@@ -387,13 +517,16 @@ fn encode_inner(
|
||||
write_index,
|
||||
client_token,
|
||||
timestamp,
|
||||
))
|
||||
dst,
|
||||
);
|
||||
}
|
||||
WriteValue::StringArray(arr) => {
|
||||
WriteValue::StringArray(arr) | WriteValue::DateTimeArray(arr) => {
|
||||
let count = value.array_count().ok_or_else(array_too_large)?;
|
||||
// Variable arrays hard-code element_width = 4 (`NmxWriteMessage.cs:30, 52`).
|
||||
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,
|
||||
kind,
|
||||
&value_bytes,
|
||||
@@ -402,23 +535,162 @@ fn encode_inner(
|
||||
write_index,
|
||||
client_token,
|
||||
timestamp,
|
||||
))
|
||||
}
|
||||
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,
|
||||
))
|
||||
dst,
|
||||
);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -431,21 +703,53 @@ fn array_too_large() -> CodecError {
|
||||
|
||||
// ---- 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
|
||||
/// (`NmxWriteMessage.cs:121-128`); the timestamped form uses a single-byte
|
||||
/// payload with the 14-byte timestamped suffix (`NmxWriteMessage.cs:130-137`).
|
||||
fn encode_boolean(
|
||||
fn write_boolean_body(
|
||||
handle: &MxReferenceHandle,
|
||||
value: bool,
|
||||
write_index: i32,
|
||||
client_token: u32,
|
||||
timestamp: Option<i64>,
|
||||
) -> Vec<u8> {
|
||||
body: &mut [u8],
|
||||
) {
|
||||
if let Some(filetime) = timestamp {
|
||||
// Timestamped: 1-byte payload + 14-byte timestamped suffix + 4-byte index.
|
||||
// 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(&mut body, handle, WriteValueKind::Boolean);
|
||||
write_common_prefix(body, handle, WriteValueKind::Boolean);
|
||||
body[KIND_OFFSET + 1] = if value { 0xff } else { 0x00 };
|
||||
write_timestamped_suffix(
|
||||
&mut body[KIND_OFFSET + 2..],
|
||||
@@ -453,35 +757,31 @@ fn encode_boolean(
|
||||
write_index,
|
||||
client_token,
|
||||
);
|
||||
body
|
||||
} else {
|
||||
// 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 mut body = vec![0u8; KIND_OFFSET + 1 + value_bytes.len() + 11 + 4];
|
||||
write_common_prefix(&mut body, handle, WriteValueKind::Boolean);
|
||||
write_common_prefix(body, handle, WriteValueKind::Boolean);
|
||||
body[KIND_OFFSET + 1..KIND_OFFSET + 1 + value_bytes.len()].copy_from_slice(&value_bytes);
|
||||
write_boolean_suffix(
|
||||
&mut body[KIND_OFFSET + 1 + value_bytes.len()..],
|
||||
write_index,
|
||||
client_token,
|
||||
);
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed-size scalar (Int32, Float32, Float64). Mirrors `CreateFixed` /
|
||||
/// `CreateFixedTimestamped` (`NmxWriteMessage.cs:112-119, 139-146`).
|
||||
fn encode_fixed(
|
||||
fn write_fixed_body(
|
||||
handle: &MxReferenceHandle,
|
||||
kind: WriteValueKind,
|
||||
value_bytes: &[u8],
|
||||
write_index: i32,
|
||||
client_token: u32,
|
||||
timestamp: Option<i64>,
|
||||
) -> Vec<u8> {
|
||||
let mut body = vec![0u8; KIND_OFFSET + 1 + value_bytes.len() + 14 + 4];
|
||||
write_common_prefix(&mut body, handle, kind);
|
||||
body: &mut [u8],
|
||||
) {
|
||||
write_common_prefix(body, handle, kind);
|
||||
body[KIND_OFFSET + 1..KIND_OFFSET + 1 + value_bytes.len()].copy_from_slice(value_bytes);
|
||||
let suffix_start = KIND_OFFSET + 1 + value_bytes.len();
|
||||
match timestamp {
|
||||
@@ -490,28 +790,26 @@ fn encode_fixed(
|
||||
}
|
||||
None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token),
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Variable-length payload (String, DateTime). Mirrors `CreateVariable` /
|
||||
/// `CreateVariableTimestamped` (`NmxWriteMessage.cs:148-168`). Total length
|
||||
/// is `44 + utf16_bytes_len`.
|
||||
fn encode_variable(
|
||||
fn write_variable_body(
|
||||
handle: &MxReferenceHandle,
|
||||
kind: WriteValueKind,
|
||||
value_bytes: &[u8],
|
||||
write_index: i32,
|
||||
client_token: u32,
|
||||
timestamp: Option<i64>,
|
||||
) -> Vec<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(&mut body, handle, kind);
|
||||
body: &mut [u8],
|
||||
) {
|
||||
write_common_prefix(body, handle, kind);
|
||||
// body[18..22] = outer_length = N + 4 (`NmxWriteMessage.cs:152, 163`)
|
||||
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`)
|
||||
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 + value_bytes.len()].copy_from_slice(value_bytes);
|
||||
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),
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Array body. Mirrors `CreateArray` / `CreateArrayTimestamped`
|
||||
/// (`NmxWriteMessage.cs:170-205`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn encode_array(
|
||||
fn write_array_body(
|
||||
handle: &MxReferenceHandle,
|
||||
kind: WriteValueKind,
|
||||
value_bytes: &[u8],
|
||||
@@ -536,16 +833,15 @@ fn encode_array(
|
||||
write_index: i32,
|
||||
client_token: u32,
|
||||
timestamp: Option<i64>,
|
||||
) -> Vec<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(&mut body, handle, kind);
|
||||
body: &mut [u8],
|
||||
) {
|
||||
write_common_prefix(body, handle, kind);
|
||||
// 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`).
|
||||
write_u16_le(&mut body, 24, element_width);
|
||||
// body[18..22] and body[26..28] are zero-initialised by vec! and not
|
||||
// written by the .NET reference either — they remain zero.
|
||||
write_u16_le(body, 24, element_width);
|
||||
// body[18..22] and body[26..28] are zero-initialised by the dispatcher's
|
||||
// resize and not written by the .NET reference either — they remain zero.
|
||||
body[28..28 + value_bytes.len()].copy_from_slice(value_bytes);
|
||||
let suffix_start = 28 + value_bytes.len();
|
||||
match timestamp {
|
||||
@@ -554,7 +850,6 @@ fn encode_array(
|
||||
}
|
||||
None => write_normal_suffix(&mut body[suffix_start..], write_index, client_token),
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
// ---- Prefix and suffix writers --------------------------------------------
|
||||
@@ -1578,7 +1873,7 @@ mod tests {
|
||||
expected.extend_from_slice(&[0x01, 0x00]); // .cs:210 (version=1)
|
||||
expected.extend_from_slice(&projection); // .cs:211
|
||||
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]);
|
||||
// 7-byte zero region of Boolean suffix (.cs:235)
|
||||
expected.extend_from_slice(&[0; 7]);
|
||||
|
||||
@@ -2952,8 +2952,16 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_then_unsubscribe_round_trip() {
|
||||
// Two RPCs: AdviseSupervisory + UnAdvise. Both return HRESULT 0.
|
||||
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await;
|
||||
// Four RPCs: Connect + AddSubscriberEngine (F56's
|
||||
// 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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -3004,12 +3012,15 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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![
|
||||
(0, Vec::new()),
|
||||
(0, Vec::new()),
|
||||
(0, Vec::new()),
|
||||
(0, Vec::new()),
|
||||
(0, Vec::new()),
|
||||
(0, Vec::new()),
|
||||
])
|
||||
.await;
|
||||
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() {
|
||||
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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -3287,7 +3300,9 @@ mod tests {
|
||||
async fn subscription_stream_filters_out_mismatched_correlation_for_status() {
|
||||
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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -3322,8 +3337,9 @@ mod tests {
|
||||
use futures_util::StreamExt;
|
||||
// 0x33 DataUpdate has no item_correlation_id; the .NET-style
|
||||
// filter passes them through to all subscriptions.
|
||||
|
||||
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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -3472,7 +3488,15 @@ mod tests {
|
||||
// F16: every successful subscribe() inserts into the
|
||||
// SubscriptionEntry registry; unsubscribe() removes it.
|
||||
// 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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -3802,8 +3826,15 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_returns_first_data_change_within_timeout() {
|
||||
// Server: AdviseSupervisory ack + UnAdvise ack.
|
||||
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await;
|
||||
// Server: 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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -3851,9 +3882,16 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_returns_timeout_when_no_data_arrives() {
|
||||
// Server only handles the AdviseSupervisory + UnAdvise (no data
|
||||
// injection). Read must hit the timeout branch.
|
||||
let (addr, handle) = unauthenticated_server(vec![(0, Vec::new()), (0, Vec::new())]).await;
|
||||
// Server only handles the Connect + AddSubscriberEngine (F56) +
|
||||
// AdviseSupervisory + UnAdvise (no data injection). Read must
|
||||
// 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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
@@ -4407,21 +4445,29 @@ mod tests {
|
||||
/// the negative control; this test pins the buffered branch.
|
||||
#[tokio::test]
|
||||
async fn unsubscribe_skips_un_advise_for_buffered_subscription() {
|
||||
let (addr, recorded, handle) =
|
||||
recording_server(vec![(0, Vec::new()), (0, Vec::new())]).await;
|
||||
// Three RPCs: Connect + AddSubscriberEngine (F56) +
|
||||
// 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(&[(
|
||||
"TestObj.TestInt",
|
||||
sample_metadata(),
|
||||
)]));
|
||||
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 cid = sub.correlation_id;
|
||||
assert_eq!(
|
||||
recorded.lock().unwrap().len(),
|
||||
1,
|
||||
"subscribe should issue 1 RPC"
|
||||
3,
|
||||
"subscribe should issue 3 RPCs (Connect + AddSubscriberEngine + AdviseSupervisory)"
|
||||
);
|
||||
|
||||
// Mutate the registry entry's mode to Buffered (synthesise the
|
||||
@@ -4438,13 +4484,14 @@ mod tests {
|
||||
}
|
||||
|
||||
// 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();
|
||||
assert_eq!(
|
||||
recorded.lock().unwrap().len(),
|
||||
1,
|
||||
"buffered unsubscribe must not issue UnAdvise; recorded RPC count must stay at 1 \
|
||||
(the original AdviseSupervisory)"
|
||||
3,
|
||||
"buffered unsubscribe must not issue UnAdvise; recorded RPC count must stay at 3"
|
||||
);
|
||||
|
||||
// Registry is still cleared — F47's skip applies only to the
|
||||
|
||||
@@ -67,12 +67,23 @@ function Set-LiveEnvVar {
|
||||
|
||||
function Get-InfisicalSecret {
|
||||
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 {
|
||||
$value = & $GetSecret -Key $Key -Env $Env -Path $Path 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $value) {
|
||||
throw "Get-Secret returned empty for $Env$Path/$Key (exit code $LASTEXITCODE)"
|
||||
$value = & $GetSecret -Key $Key -Env $Env -Path $Path
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
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()
|
||||
} catch {
|
||||
throw "Failed to fetch $Env$Path/$Key from Infisical: $_"
|
||||
|
||||
Reference in New Issue
Block a user