Surfaced during the OTel metrics PR review (separate from that PR).
NatsSubBase has two independent gates (_endSubscription and
_unsubscribed) and a _exception field that is written before
the reason gate. As a result, NatsSubBase.EndReason can be:
-
Inconsistent with Exception. If a timer or cancellation token
fires before an exception path runs, _exception is set but the
reason field still shows Cancelled / IdleTimeout / etc., not
Exception. The two fields disagree.
-
Stuck at None for connection-driven drain. DrainAsync sets
_unsubscribed = true directly and never calls EndSubscription.
There is no Drained / ConnectionClosed enum value.
-
Stuck at None for user-driven DisposeAsync. Same shape: no
reason is recorded.
-
Silently overwritten by the "first wins" gate when two end conditions
race (two timers, timer + exception, etc.). The second caller's
reason is dropped without surfacing the contention.
Possible directions (not prescriptive):
- Prioritise
Exception over previously-set reasons when _exception
is written.
- Add
Drained / ConnectionClosed enum values and call
EndSubscription from DrainAsync and the connection-dispose path.
- Document the "None means user-disposed" convention if that is the
intended contract.
Surfaced during the OTel metrics PR review (separate from that PR).
NatsSubBase has two independent gates (
_endSubscriptionand_unsubscribed) and a_exceptionfield that is written beforethe reason gate. As a result,
NatsSubBase.EndReasoncan be:Inconsistent with
Exception. If a timer or cancellation tokenfires before an exception path runs,
_exceptionis set but thereason field still shows
Cancelled/IdleTimeout/ etc., notException. The two fields disagree.Stuck at
Nonefor connection-driven drain.DrainAsyncsets_unsubscribed = truedirectly and never callsEndSubscription.There is no
Drained/ConnectionClosedenum value.Stuck at
Nonefor user-drivenDisposeAsync. Same shape: noreason is recorded.
Silently overwritten by the "first wins" gate when two end conditions
race (two timers, timer + exception, etc.). The second caller's
reason is dropped without surfacing the contention.
Possible directions (not prescriptive):
Exceptionover previously-set reasons when_exceptionis written.
Drained/ConnectionClosedenum values and callEndSubscriptionfromDrainAsyncand the connection-dispose path.intended contract.