CONSUMER.RESET response silently dropped across a service import (same-client no-echo check) — cross-account source/mirror bind-by-name hangs at active: -1
Summary
When a JetStream source (or mirror) binds a pre-created consumer by name across an account boundary (via a service export/import), the server generates a valid consumer_reset_response, but that response is silently discarded inside the exporting account and never reaches the requester. The sourcing side then hangs forever at active: -1, with no error surfaced anywhere — no log, no advisory, no NAK.
Binding a pre-created consumer by name (rather than granting CONSUMER.CREATE) is the natural way to avoid the DeliverSubject-in-request-body ACL gap discussed in #8274 (open) / #8279 (closed as won't-fix), so this bug makes the safe cross-account consumer-attach pattern unusable in exactly the multi-tenant setups that need it.
Environment
- nats-server 2.14.3.
processResetReqis byte-identical from 2.14.0 throughmain(checked againstmainon 2026-07-22) — no released version fixes it.- Topology: a JetStream hub + a leaf node attaching via a per-account leaf remote; the hub stream is reached cross-account via a literal, account-pinned stream export/import.
Steps to reproduce
- On a JetStream hub, create an
interest-retention stream, e.g.OPAL_WALwith subjectsopal.wal.>. - Create a second account with
jetstream: disabledthat imports the stream via a literal, account-pinned stream export (noCONSUMER.CREATEgrant on the importing account). - With a hub-side credential, pre-create a durable consumer in the exporting account with a fixed
DeliverSubject. - From a leaf attached to the importing account, create a local
source(ormirror) that binds that consumer by name (StreamSource.External+StreamSource.Consumer). Binding an existing consumer this way makes the server emitCONSUMER.RESETrather thanCONSUMER.CREATE.
Observed: the leaf emits CONSUMER.RESET (not CREATE); the hub generates a valid consumer_reset_response; delivery works at every hop — but the response never returns to the leaf, so the source/mirror stalls at active: -1 indefinitely with nothing surfaced.
Expected: the consumer_reset_response is delivered back across the service import, and the source/mirror binds and begins flowing.
Root cause
The reset response is dropped inside the exporting account by the same-client no-echo check in client.go (around the processMsgResults recipient loop):
if client == nil || (c == client && !client.echo && !sub.si) { /* skip */ }- A service import registers the reply path (
_R_.<hash>.>) on the exporting account's internal client. processResetReq(consumer.go) replies viasendInternalAccountMsg(a, …)— i.e. it publishes from that same internal client, with echo off.- Publisher == subscriber, echo is off, and the response-import subscription carries no service-import (
si) exemption ⇒ the message is silently discarded by the check above.
Why only CONSUMER.RESET breaks (and CONSUMER.CREATE etc. do not)
Every registered JS API verb replies via sendAPIResponse → sendInternalAccountMsg(nil, …) → s.sys.client, which is a different client — so the no-echo check does not fire, and CONSUMER.CREATE, STREAM.INFO, and friends cross accounts fine.
CONSUMER.RESET is the only per-consumer-subscription verb: it has no global handler (the jetstream_api.go entry is a template only, with no registration-table entry), and its subscription is created per-consumer in setLeader (consumer.go). So it replies from the same internal client that owns the import reply subscription — the exact condition the check above drops.
Isolating control (leader vs non-leader entry node)
On a 2-node cluster the identical bind flows when the leaf enters via the non-leader node and stalls when it enters via the leader node. When the leaf enters via a non-leader, the reply travels via a route (a ROUTER client — a different client), so the no-echo check does not apply and the response is delivered. When the leaf enters via the node that leads the consumer, publisher and subscriber are the same internal client and the response is dropped. This route-hop difference isolates the cause precisely.
Impact
Cross-account consumer bind-by-name — the isolation-preserving alternative to granting CONSUMER.CREATE — is unusable whenever the consumer leader sits on the node the client entered through. The only workaround we have found is a topology constraint: ensure the consumer can never be led by the entry node (for example, a JetStream-disabled node tier that leaves attach to, forcing the reset response across a route). That is a deployment workaround, not a fix.
Suggested fix directions
Either:
- Give the service-import response subscription the
si(service-import) exemption so the same-client no-echo check does not drop it; or - Make
processResetReqreply the way every other registered JS API verb does — viasendAPIResponse/s.sys.client(a different client) — so the same-client no-echo condition never applies to reset responses.
Fixing this would remove the need for the JetStream-disabled node-tier workaround entirely: any node could then host the consumer.
Related
- #8274 (open) and #8279 (closed won't-fix) — the
DeliverSubject-in-request-body ACL gap that makes bind-by-name the preferred pattern.
Source: nats-io/nats-server