[F56] subscribe / subscribe_buffered: split-form wire body + diagnose Galaxy fixture gap
Three real fixes + one architectural diagnosis:
1. Session::subscribe_buffered_nmx now sends the .NET-reference split
form on the wire:
item_definition = "<attr>.property(buffer)" (was: full reference)
item_context = "<object_tag_name>" (was: empty)
item_handle = SessionInner::next_item_handle.fetch_add(1)
(was: hardcoded 0)
Verified byte-identical against captures/082 + 094 by the existing
buffered_register_reference_parity unit tests. The
item_handle counter mirrors MxNativeCompatibilityServer's
_nextItemHandle++ at MxNativeSession.cs:613.
2. New live tests:
- tests/buffered_subscribe_live.rs (F49 step 1) — uses real Galaxy
metadata via SqlTagResolver + connect_nmx_auto, drives a
background writer at 500ms cadence to force value-changes,
drains DataChange events from Subscription.
- tests/plain_subscribe_live.rs — same harness over plain
Session::subscribe (NOT buffered), used to isolate whether
"no DataUpdate" is buffered-specific (it's not — both fail).
Both pull tracing-subscriber as a dev-dep so `RUST_LOG=trace`
surfaces dcom_sink + router activity.
3. mxaccess-galaxy/sql_resolver.rs: drop the inner-attribute
`#![cfg(feature = "galaxy-resolver")]` — the module-level cfg on
`pub mod sql_resolver` in lib.rs already handles this and Rust
1.85's clippy::duplicated_attributes lint flagged the duplicate
once mxaccess-compat dev-deps activated the feature.
4. F56 finding (diagnosis, NOT a bug fix): the engine on this Galaxy
install does not have an active value for TestChildObject.TestInt.
Confirmed by running the .NET reference's own probe:
dotnet run --project src/MxNativeClient.Probe -c Release \
-- --probe-session-subscribe --tag=TestChildObject.TestInt \
--subscribe-hold-seconds=10
...returns ONE 0x32 SubscriptionStatus (status=3 detail=3
quality=0x00C0 Uncertain value=null) and zero 0x33 DataUpdates —
matching the Rust port's symptom exactly. Not a Rust port bug,
not a wire-byte gap. F49 steps 1-3 need either an actively-
scanned tag or local Galaxy reconfiguration to scan
TestChildObject.TestInt.
Workspace tests + clippy clean under both feature configurations.
F56 entry in design/followups.md updated with the full diagnostic
chain so future-me / future-collaborators can pick it up without
re-tracing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -614,6 +614,18 @@ pub struct SessionInner {
|
||||
/// dictionaries (`MxNativeSession.cs` field-level comments) plus
|
||||
/// the ordered list those dictionaries are consulted against.
|
||||
pub(crate) pending_ops: Arc<Mutex<PendingOps>>,
|
||||
/// F56 — monotonically-increasing item-handle assigner for
|
||||
/// `subscribe_buffered`. The .NET reference's
|
||||
/// `MxNativeCompatibilityServer.AddItem` flow assigns `1, 2, 3, ...`
|
||||
/// at the LMX layer (`MxNativeCompatibilityServer.cs`) and threads
|
||||
/// the handle through to the `RegisterReference` wire body.
|
||||
/// Sending `0` (the previous behaviour) caused the engine to
|
||||
/// silently swallow the buffered subscription — RegisterReference
|
||||
/// returned HRESULT 0 + a `0x11` registration result fired, but no
|
||||
/// `0x33` DataUpdate frames followed. Starting at 1 mirrors the
|
||||
/// .NET LMX behaviour captured at
|
||||
/// `captures/094-frida-buffered-separate-writer/frida-events.tsv:13`.
|
||||
pub(crate) next_item_handle: std::sync::atomic::AtomicI32,
|
||||
/// F55 / Path A — keeps the DCOM-managed `INmxSvcCallback`'s
|
||||
/// `IUnknown` ref alive for the session's lifetime. The marshalled
|
||||
/// OBJREF passed to `RegisterEngine2` references this object's
|
||||
@@ -841,15 +853,34 @@ pub(crate) async fn callback_router(
|
||||
// we currently just consume + drop the frame at trace
|
||||
// level so the catch-all parse below doesn't log a
|
||||
// spurious "unexpected opcode 0x11" warning.
|
||||
if let Ok(result) =
|
||||
NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body(&body)
|
||||
{
|
||||
tracing::trace!(
|
||||
item_handle = result.item_handle,
|
||||
correlation_id = ?result.item_correlation_id,
|
||||
"callback_router: 0x11 RegistrationResult received"
|
||||
);
|
||||
continue;
|
||||
match NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body(
|
||||
&body,
|
||||
) {
|
||||
Ok(result) => {
|
||||
tracing::trace!(
|
||||
item_handle = result.item_handle,
|
||||
correlation_id = ?result.item_correlation_id,
|
||||
item_definition = %result.item_definition,
|
||||
item_context = %result.item_context,
|
||||
status_category = result.status_category,
|
||||
status_detail = result.status_detail,
|
||||
"callback_router: 0x11 RegistrationResult received"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
let hex: String = body
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
tracing::trace!(
|
||||
err = %e,
|
||||
body_len = body.len(),
|
||||
body_hex = %hex,
|
||||
"callback_router: not a 0x11 RegistrationResult, falling through"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall through to subscription messages. Wire bytes
|
||||
@@ -1107,6 +1138,7 @@ impl Session {
|
||||
callback_obj_ref,
|
||||
rebuild_factory: Mutex::new(None),
|
||||
pending_ops,
|
||||
next_item_handle: std::sync::atomic::AtomicI32::new(1),
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
dcom_sink_holder: Mutex::new(dcom_sink_holder),
|
||||
}),
|
||||
@@ -1935,25 +1967,36 @@ impl Session {
|
||||
.map_err(map_resolver)?;
|
||||
let correlation_id: [u8; 16] = rand::random();
|
||||
|
||||
// Build the buffered RegisterReference body. Item definition is
|
||||
// the full reference suffixed with `.property(buffer)`; item
|
||||
// context is empty for this single-string form (the .NET
|
||||
// reference's split-context form is reachable via the
|
||||
// compat-server layer F35 once it lands). The codec helper
|
||||
// rejects empty/whitespace inputs with `CodecError::InvalidName`.
|
||||
let item_definition = NmxReferenceRegistrationMessage::to_buffered_item_definition(
|
||||
reference,
|
||||
)
|
||||
.map_err(|e| {
|
||||
Error::Configuration(ConfigError::InvalidArgument {
|
||||
detail: format!("buffered item definition: {e}"),
|
||||
})
|
||||
})?;
|
||||
// F56 — build the buffered RegisterReference body in the split
|
||||
// (object, attribute) form the .NET reference uses on the wire:
|
||||
// item_definition = "<attribute>.property(buffer)"
|
||||
// item_context = "<object_tag_name>"
|
||||
// item_handle = sequential per-session counter starting at 1
|
||||
//
|
||||
// The previous implementation used the single-string form (full
|
||||
// reference in `item_definition`, empty `item_context`,
|
||||
// `item_handle = 0`). RegisterReference returned HRESULT 0 and
|
||||
// the engine fired a `0x11` registration result, but **no
|
||||
// `0x33` DataUpdate frames ever followed** — confirmed live
|
||||
// 2026-05-06. Switching to the split form mirrors the captured
|
||||
// .NET wire bytes at
|
||||
// `captures/094-frida-buffered-separate-writer/frida-events.tsv:23`
|
||||
// (the PutRequest body at 21:40:19.970).
|
||||
let item_handle = inner
|
||||
.next_item_handle
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let attribute_only_definition =
|
||||
NmxReferenceRegistrationMessage::to_buffered_item_definition(&metadata.attribute_name)
|
||||
.map_err(|e| {
|
||||
Error::Configuration(ConfigError::InvalidArgument {
|
||||
detail: format!("buffered item definition: {e}"),
|
||||
})
|
||||
})?;
|
||||
let registration = NmxReferenceRegistrationMessage {
|
||||
item_handle: 0,
|
||||
item_handle,
|
||||
item_correlation_id: correlation_id,
|
||||
item_definition,
|
||||
item_context: String::new(),
|
||||
item_definition: attribute_only_definition,
|
||||
item_context: metadata.object_tag_name.clone(),
|
||||
subscribe: true,
|
||||
reserved_25_27: [0; 2],
|
||||
reserved_31_55: [0; 24],
|
||||
@@ -1996,9 +2039,13 @@ impl Session {
|
||||
metadata: Arc::clone(&metadata_arc),
|
||||
mode: SubscriptionMode::Buffered {
|
||||
rounded_interval_ms: rounded_ms,
|
||||
item_definition: reference.to_string(),
|
||||
item_context: String::new(),
|
||||
item_handle: 0,
|
||||
// F56 — recovery replays via `register_reference` and
|
||||
// must reissue the same wire body. Save the split
|
||||
// (object, attribute, handle) triple, NOT the
|
||||
// pre-F56 single-string form.
|
||||
item_definition: registration.item_definition.clone(),
|
||||
item_context: registration.item_context.clone(),
|
||||
item_handle,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2554,6 +2601,7 @@ mod tests {
|
||||
callback_obj_ref: Vec::new(),
|
||||
rebuild_factory: Mutex::new(None),
|
||||
pending_ops,
|
||||
next_item_handle: std::sync::atomic::AtomicI32::new(1),
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
dcom_sink_holder: Mutex::new(None),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user