Skip to content

Commit de24381

Browse files
authored
refactor(subscriptions): drop dispatcher-intercepted RESPONSE_MESSAGE_IDS entries (#734)
Audit of every declared entry against the meaning #732 gave the const — "the complete set of types that reach `decode`". Sixteen decoders declared IncomingMessages::Error, which determine_routing classifies before the allow-list: an error reaches a subscription as RoutedItem::Error/Notice and never as the Response that decode consumes. The declarations and their decode arms are removed. routable_to_request_id_subscription loses its Error exemption, which existed only so those declarations would not trip the routing guard — const declares, guard exempts, circular. The circle is broken from the const side. test_response_message_ids_match_decode_arms now diagnoses Error and Shutdown specifically instead of reporting them as generic declared-but-unhandled. The proof also covers the one-shot path: fold_one_shot runs its processor only on Some(Ok(_)), and RoutedItem::into_legacy yields that only for Response. wsh's two nested dispatchers are shared with a StreamDecoder and lose their Error arm here; the remaining one-shot arms are a follow-up. Drops 15 decoder-level test_decode_error_message* tests plus the two historical streaming-error tests. All asserted a path only MessageBusStub can produce — it delivers every fixture as RoutedItem::Response. The behaviour is covered where it is implemented, by test_hard_error_with_request_id_terminates_subscription and test_subscription_hard_error_terminates_stream in both transports. No production behaviour change: the removed arms were unreachable. Closes the third follow-up in plans/claude-md-knowledge-graph.md.
1 parent e2c7be8 commit de24381

22 files changed

Lines changed: 143 additions & 311 deletions

docs/rules/wire/proto-aware-accessors.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,16 @@ live-gateway smoke test surfaced the gap.
4747
`debug_assert_request_id_routable` (`src/subscriptions/common.rs`) runs in both subscription
4848
constructors and panics when a `request_id`-keyed subscription is built for a decoder whose
4949
`RESPONSE_MESSAGE_IDS` names a type the dispatcher cannot route to it. The classification is
50-
`first_unroutable_by_request_id` in `src/transport/routing.rs`, whose three accepting arms
51-
mirror `determine_routing`: `Error`, order-scoped types, and anything with a
52-
`text_request_id_field` entry.
50+
`first_unroutable_by_request_id` in `src/transport/routing.rs`, whose two accepting arms
51+
mirror `determine_routing`: order-scoped types, and anything with a `text_request_id_field`
52+
entry.
53+
54+
`Error` was a third accepting arm until #734. It is not routable by `request_id` — it has no
55+
`text_request_id_field` entry — but sixteen decoders declared it, so the guard exempted it to
56+
keep them passing: const declares, guard exempts, circular. `determine_routing` classifies
57+
`Error` before the allow-list, so it reaches a subscription as `RoutedItem::Error`/`Notice`
58+
and never as a `Response` for `decode` to see. The declarations were the wrong half; they were
59+
removed and the exemption with them.
5360

5461
Stub tests bypass the dispatcher but not the constructor, so this fires in exactly the tests
5562
that used to pass silently. It is compiled out of release builds — the invariant is over

docs/rules/wire/proto-only-decoding.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ triggers:
1010
- adding a decode arm for a new message type
1111
symbols: [require_proto, process_decode_result, StreamDecoder, RESPONSE_MESSAGE_IDS, Error::UnexpectedResponse, Error::UnexpectedWireFormat]
1212
related: [proto-aware-accessors, enum-typing, fixture-builders]
13-
precedents: ["#508", "#731", "#732"]
13+
precedents: ["#508", "#731", "#732", "#733", "#734"]
1414
memory: [project_protobuf_only, feedback_unreachable_regression_guards]
1515
---
1616

@@ -41,6 +41,14 @@ Its companion `test_decoder_roster_is_complete` counts `impl StreamDecoder` bloc
4141
against the hand-listed roster, so adding a decoder without registering it fails rather than
4242
going unchecked.
4343

44+
**Never declare `IncomingMessages::Error` or `Shutdown`, and never write a `decode` arm for
45+
them.** `determine_routing` classifies both before any allow-list, so an error reaches the
46+
subscription as `RoutedItem::Error`/`Notice` and a shutdown ends the dispatcher loop — neither
47+
ever arrives as the `RoutedItem::Response` that `decode` consumes. The same holds for one-shot
48+
requests, which read through `RoutedItem::into_legacy`: an error becomes `Some(Err(_))` and the
49+
processor never runs. Sixteen decoders declared `Error` anyway; the check above names the
50+
mistake specifically.
51+
4452
```rust
4553
pub(in crate::news) fn decode_news_bulletin(message: &ResponseMessage) -> Result<NewsBulletin, Error> {
4654
decode_news_bulletin_proto(message.require_proto()?)
@@ -94,3 +102,7 @@ other two:
94102
- #731 — split the framing failure out of the skip path so the fixture trap fails loudly.
95103
- #732 — retired skip-by-error-variant entirely; `RESPONSE_MESSAGE_IDS` is now the filter and
96104
the const lost its default so every impl must declare one.
105+
- #733 — gated both drift directions; the roster is counted against the tree.
106+
- #734 — audited all 78 declared entries under the const's new meaning. Sixteen declared
107+
`IncomingMessages::Error`, which the dispatcher intercepts; those and their `decode` arms are
108+
gone, along with the guard exemption that had been keeping them legal.

plans/claude-md-knowledge-graph.md

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -442,14 +442,52 @@ life.
442442
semantics has to be made twice. The rule node now says so out loud rather than claiming
443443
there are two drivers.
444444

445-
- **Audit the 41 `RESPONSE_MESSAGE_IDS` entries against the const's new meaning.** Eight
446-
decoders declare `IncomingMessages::Error`, which `determine_routing` classifies *before* the
447-
allow-list — it arrives as `RoutedItem::Error`/`Notice` and can never reach `decode`.
448-
Harmless, but #732 redefined the const as "the complete set of types that reach `decode`",
449-
and under that contract declaring an unreachable type is exactly the kind of misleading
450-
declaration the change set out to remove. `routable_to_request_id_subscription` already
451-
special-cases `Error` to stop those declarations tripping the routing guard — const declares,
452-
guard exempts, circular.
445+
- ~~**Audit the 41 `RESPONSE_MESSAGE_IDS` entries against the const's new meaning.**~~
446+
**Shipped.** Both numbers in the original bullet were wrong, and the audit is what caught
447+
them: there are **78** declared entries, not 41, and **16** decoders declared
448+
`IncomingMessages::Error`, not eight. Counting them took one throwaway test over the roster
449+
#733 had already built — which is the argument for building the roster, and one more instance
450+
of the count class this file keeps failing.
451+
452+
The finding itself held. `determine_routing` classifies `Error` before the allow-list, so it
453+
arrives as `RoutedItem::Error`/`Notice` and never as the `Response` that `decode` consumes.
454+
All 16 declarations and their `decode` arms are gone, and `routable_to_request_id_subscription`
455+
lost the `Error` exemption that had been keeping them legal — the circle is broken from the
456+
const side, not the guard side. `test_response_message_ids_match_decode_arms` now names the
457+
mistake specifically for `Error` and `Shutdown` rather than reporting it as a generic
458+
declared-but-unhandled failure.
459+
460+
**The proof generalises further than the const.** The one-shot request path reads through
461+
`RoutedItem::into_legacy`, which maps an error to `Some(Err(_))` and never runs the
462+
processor — so every `IncomingMessages::Error` arm in a one-shot `decode_*_message` is dead
463+
too. Two of those (`wsh::decode_metadata_message`, `decode_event_data_message`) are shared
464+
with a `StreamDecoder` and were removed here; the rest are a separate follow-up below.
465+
466+
Removing the arms cost 15 decoder-level `test_decode_error_message*` tests, all asserting a
467+
path only `MessageBusStub` can produce. The behaviour they claimed to cover is tested at both
468+
layers that actually implement it — `test_hard_error_with_request_id_terminates_subscription`
469+
and `test_subscription_hard_error_terminates_stream`, in each transport's tests.
470+
471+
- **Retire the remaining one-shot `IncomingMessages::Error` arms.** The audit above proved the
472+
whole class dead, not just the `StreamDecoder` half: `fold_one_shot` only ever calls its
473+
processor on `Some(Ok(_))`, and `RoutedItem::into_legacy` yields that only for
474+
`RoutedItem::Response`. So the `Error` arms in `accounts::decode_soft_dollar_tiers_message` /
475+
`decode_user_info_message` / `decode_replace_fa_end_message`,
476+
`contracts::decode_smart_components_message`, `scanner::decode_scanner_message`, and both
477+
`config::common::decoders` dispatchers can never fire, as can the inline
478+
`Ok(message) if message.message_type() == IncomingMessages::Error` guards in
479+
`contracts::{sync,async}` and `market_data::historical::{sync,async}`. Mechanical, but each
480+
deletion needs its test checked — the same 15-test cost pattern as #734. Left out of #734 to
481+
keep that PR's boundary at the surface the follow-up named.
482+
483+
- **Make `MessageBusStub` classify like the dispatcher.** It hands every fixture to the
484+
subscription as `RoutedItem::Response`, including `Error` frames, which the real dispatcher
485+
never does — that mismatch is why 15 decoder-level error tests existed and passed. This is
486+
the same structural blind spot #730 recorded (stub tests inject below `determine_routing`),
487+
seen from the fixture side rather than the routing side. Running fixtures through
488+
`determine_routing`/`classify_error` in `mock_request` would close it; the risk is the tail of
489+
stub tests using warning or data-advisory codes, which would become `RoutedItem::Notice` and
490+
be skipped rather than surfacing as `Err`.
453491

454492
- **Cache the message discriminant on `ResponseMessage`.** `message_type()` re-parses
455493
`fields[0]` with `i32::from_str` on every call, and it is called 4–6 times per inbound

src/accounts/common/stream_decoders/mod.rs

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,12 @@ use super::{decoders, encoders};
1212
use crate::common::error_helpers;
1313

1414
impl StreamDecoder<AccountSummaryResult> for AccountSummaryResult {
15-
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[
16-
IncomingMessages::AccountSummary,
17-
IncomingMessages::AccountSummaryEnd,
18-
IncomingMessages::Error,
19-
];
15+
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::AccountSummary, IncomingMessages::AccountSummaryEnd];
2016

2117
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
2218
match message.message_type() {
2319
IncomingMessages::AccountSummary => Ok(AccountSummaryResult::Summary(decoders::decode_account_summary(message)?)),
2420
IncomingMessages::AccountSummaryEnd => Ok(AccountSummaryResult::End),
25-
IncomingMessages::Error => Err(Error::from(message.clone())),
2621
_ => Err(Error::unexpected_response(message)),
2722
}
2823
}
@@ -34,12 +29,11 @@ impl StreamDecoder<AccountSummaryResult> for AccountSummaryResult {
3429
}
3530

3631
impl StreamDecoder<PnL> for PnL {
37-
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::PnL, IncomingMessages::Error];
32+
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::PnL];
3833

3934
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
4035
match message.message_type() {
4136
IncomingMessages::PnL => decoders::decode_pnl(message),
42-
IncomingMessages::Error => Err(Error::from(message.clone())),
4337
_ => Err(Error::unexpected_response(message)),
4438
}
4539
}
@@ -51,12 +45,11 @@ impl StreamDecoder<PnL> for PnL {
5145
}
5246

5347
impl StreamDecoder<PnLSingle> for PnLSingle {
54-
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::PnLSingle, IncomingMessages::Error];
48+
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::PnLSingle];
5549

5650
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
5751
match message.message_type() {
5852
IncomingMessages::PnLSingle => decoders::decode_pnl_single(message),
59-
IncomingMessages::Error => Err(Error::from(message.clone())),
6053
_ => Err(Error::unexpected_response(message)),
6154
}
6255
}
@@ -68,13 +61,12 @@ impl StreamDecoder<PnLSingle> for PnLSingle {
6861
}
6962

7063
impl StreamDecoder<PositionUpdate> for PositionUpdate {
71-
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::Position, IncomingMessages::PositionEnd, IncomingMessages::Error];
64+
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::Position, IncomingMessages::PositionEnd];
7265

7366
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
7467
match message.message_type() {
7568
IncomingMessages::Position => Ok(PositionUpdate::Position(decoders::decode_position(message)?)),
7669
IncomingMessages::PositionEnd => Ok(PositionUpdate::PositionEnd),
77-
IncomingMessages::Error => Err(Error::from(message.clone())),
7870
_ => Err(Error::unexpected_response(message)),
7971
}
8072
}
@@ -85,17 +77,12 @@ impl StreamDecoder<PositionUpdate> for PositionUpdate {
8577
}
8678

8779
impl StreamDecoder<PositionUpdateMulti> for PositionUpdateMulti {
88-
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[
89-
IncomingMessages::PositionMulti,
90-
IncomingMessages::PositionMultiEnd,
91-
IncomingMessages::Error,
92-
];
80+
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::PositionMulti, IncomingMessages::PositionMultiEnd];
9381

9482
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
9583
match message.message_type() {
9684
IncomingMessages::PositionMulti => Ok(PositionUpdateMulti::Position(decoders::decode_position_multi(message)?)),
9785
IncomingMessages::PositionMultiEnd => Ok(PositionUpdateMulti::PositionEnd),
98-
IncomingMessages::Error => Err(Error::from(message.clone())),
9986
_ => Err(Error::unexpected_response(message)),
10087
}
10188
}
@@ -112,7 +99,6 @@ impl StreamDecoder<AccountUpdate> for AccountUpdate {
11299
IncomingMessages::PortfolioValue,
113100
IncomingMessages::AccountUpdateTime,
114101
IncomingMessages::AccountDownloadEnd,
115-
IncomingMessages::Error,
116102
];
117103

118104
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
@@ -121,7 +107,6 @@ impl StreamDecoder<AccountUpdate> for AccountUpdate {
121107
IncomingMessages::PortfolioValue => Ok(AccountUpdate::PortfolioValue(decoders::decode_account_portfolio_value(message)?)),
122108
IncomingMessages::AccountUpdateTime => Ok(AccountUpdate::UpdateTime(decoders::decode_account_update_time(message)?)),
123109
IncomingMessages::AccountDownloadEnd => Ok(AccountUpdate::End),
124-
IncomingMessages::Error => Err(Error::from(message.clone())),
125110
_ => Err(Error::unexpected_response(message)),
126111
}
127112
}
@@ -132,17 +117,12 @@ impl StreamDecoder<AccountUpdate> for AccountUpdate {
132117
}
133118

134119
impl StreamDecoder<AccountUpdateMulti> for AccountUpdateMulti {
135-
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[
136-
IncomingMessages::AccountUpdateMulti,
137-
IncomingMessages::AccountUpdateMultiEnd,
138-
IncomingMessages::Error,
139-
];
120+
const RESPONSE_MESSAGE_IDS: &'static [IncomingMessages] = &[IncomingMessages::AccountUpdateMulti, IncomingMessages::AccountUpdateMultiEnd];
140121

141122
fn decode(_context: &DecoderContext, message: &mut ResponseMessage) -> Result<Self, Error> {
142123
match message.message_type() {
143124
IncomingMessages::AccountUpdateMulti => Ok(AccountUpdateMulti::AccountMultiValue(decoders::decode_account_multi_value(message)?)),
144125
IncomingMessages::AccountUpdateMultiEnd => Ok(AccountUpdateMulti::End),
145-
IncomingMessages::Error => Err(Error::from(message.clone())),
146126
_ => Err(Error::unexpected_response(message)),
147127
}
148128
}

src/accounts/common/stream_decoders/tests.rs

Lines changed: 6 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,6 @@ mod account_summary_tests {
4646
assert!(matches!(result, AccountSummaryResult::End));
4747
}
4848

49-
#[test]
50-
fn test_decode_error_message() {
51-
// Error on the same request_id channel surfaces as Error::Notice, not a
52-
// parse failure or "unexpected message" error (#434).
53-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
54-
let err = AccountSummaryResult::decode(&test_context(), &mut message).unwrap_err();
55-
assert_tws_error_message(err, 10089, "not subscribed");
56-
}
57-
5849
#[test]
5950
fn test_cancel_message() {
6051
let bytes = AccountSummaryResult::cancel_message(TEST_SERVER_VERSION, Some(TEST_REQUEST_ID), None).unwrap();
@@ -73,11 +64,7 @@ mod account_summary_tests {
7364
fn test_response_message_ids() {
7465
assert_eq!(
7566
AccountSummaryResult::RESPONSE_MESSAGE_IDS,
76-
&[
77-
IncomingMessages::AccountSummary,
78-
IncomingMessages::AccountSummaryEnd,
79-
IncomingMessages::Error
80-
]
67+
&[IncomingMessages::AccountSummary, IncomingMessages::AccountSummaryEnd]
8168
);
8269
}
8370
}
@@ -119,14 +106,7 @@ mod pnl_tests {
119106

120107
#[test]
121108
fn test_response_message_ids() {
122-
assert_eq!(PnL::RESPONSE_MESSAGE_IDS, &[IncomingMessages::PnL, IncomingMessages::Error]);
123-
}
124-
125-
#[test]
126-
fn test_decode_error_message() {
127-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
128-
let err = PnL::decode(&test_context(), &mut message).unwrap_err();
129-
assert_tws_error_message(err, 10089, "not subscribed");
109+
assert_eq!(PnL::RESPONSE_MESSAGE_IDS, &[IncomingMessages::PnL]);
130110
}
131111
}
132112

@@ -164,14 +144,7 @@ mod pnl_single_tests {
164144

165145
#[test]
166146
fn test_response_message_ids() {
167-
assert_eq!(PnLSingle::RESPONSE_MESSAGE_IDS, &[IncomingMessages::PnLSingle, IncomingMessages::Error]);
168-
}
169-
170-
#[test]
171-
fn test_decode_error_message() {
172-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
173-
let err = PnLSingle::decode(&test_context(), &mut message).unwrap_err();
174-
assert_tws_error_message(err, 10089, "not subscribed");
147+
assert_eq!(PnLSingle::RESPONSE_MESSAGE_IDS, &[IncomingMessages::PnLSingle]);
175148
}
176149
}
177150

@@ -226,16 +199,9 @@ mod position_update_tests {
226199
fn test_response_message_ids() {
227200
assert_eq!(
228201
PositionUpdate::RESPONSE_MESSAGE_IDS,
229-
&[IncomingMessages::Position, IncomingMessages::PositionEnd, IncomingMessages::Error]
202+
&[IncomingMessages::Position, IncomingMessages::PositionEnd]
230203
);
231204
}
232-
233-
#[test]
234-
fn test_decode_error_message() {
235-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
236-
let err = PositionUpdate::decode(&test_context(), &mut message).unwrap_err();
237-
assert_tws_error_message(err, 10089, "not subscribed");
238-
}
239205
}
240206

241207
mod position_update_multi_tests {
@@ -296,20 +262,9 @@ mod position_update_multi_tests {
296262
fn test_response_message_ids() {
297263
assert_eq!(
298264
PositionUpdateMulti::RESPONSE_MESSAGE_IDS,
299-
&[
300-
IncomingMessages::PositionMulti,
301-
IncomingMessages::PositionMultiEnd,
302-
IncomingMessages::Error
303-
]
265+
&[IncomingMessages::PositionMulti, IncomingMessages::PositionMultiEnd]
304266
);
305267
}
306-
307-
#[test]
308-
fn test_decode_error_message() {
309-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
310-
let err = PositionUpdateMulti::decode(&test_context(), &mut message).unwrap_err();
311-
assert_tws_error_message(err, 10089, "not subscribed");
312-
}
313268
}
314269

315270
mod account_update_tests {
@@ -399,17 +354,9 @@ mod account_update_tests {
399354
IncomingMessages::PortfolioValue,
400355
IncomingMessages::AccountUpdateTime,
401356
IncomingMessages::AccountDownloadEnd,
402-
IncomingMessages::Error,
403357
]
404358
);
405359
}
406-
407-
#[test]
408-
fn test_decode_error_message() {
409-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
410-
let err = AccountUpdate::decode(&test_context(), &mut message).unwrap_err();
411-
assert_tws_error_message(err, 10089, "not subscribed");
412-
}
413360
}
414361

415362
mod account_update_multi_tests {
@@ -468,20 +415,9 @@ mod account_update_multi_tests {
468415
fn test_response_message_ids() {
469416
assert_eq!(
470417
AccountUpdateMulti::RESPONSE_MESSAGE_IDS,
471-
&[
472-
IncomingMessages::AccountUpdateMulti,
473-
IncomingMessages::AccountUpdateMultiEnd,
474-
IncomingMessages::Error
475-
]
418+
&[IncomingMessages::AccountUpdateMulti, IncomingMessages::AccountUpdateMultiEnd]
476419
);
477420
}
478-
479-
#[test]
480-
fn test_decode_error_message() {
481-
let mut message = proto_error_response(123, 10089, "Requested market data is not subscribed");
482-
let err = AccountUpdateMulti::decode(&test_context(), &mut message).unwrap_err();
483-
assert_tws_error_message(err, 10089, "not subscribed");
484-
}
485421
}
486422

487423
// Edge case tests

0 commit comments

Comments
 (0)