Skip to content

PUBLISH on a connection with an active subscription closes the socket (RESP3 spec violation; breaks StackExchange.Redis 3.x pub/sub) #1955

Description

@nixxcz

Describe the bug

When a client session has an active SUBSCRIBE and then issues PUBLISH on that same connection, Garnet closes the TCP socket instead of replying. This happens under both RESP2 and RESP3 (verified on 1.1.10, latest stable, and 1.1.6).

Under RESP3 this is a protocol-conformance bug: the RESP3 spec allows a subscribed connection to run any command (push messages are out-of-band and self-describing), so PUBLISH must return its integer reply. Real Redis 7.x does exactly that. Garnet instead tears the connection down.

This is now hit out-of-the-box by StackExchange.Redis 3.x (the mainstream .NET client), which defaults to RESP3 (negotiated via HELLO) and, under RESP3, multiplexes pub/sub subscriptions onto the single interactive connection. Any app that both subscribes and publishes therefore has its interactive connection killed on the first PUBLISH:

StackExchange.Redis.RedisConnectionException: SocketClosed on localhost:6379/Interactive,
Idle/RanToCompletion, last: PUBLISH, origin: ReadAllAsync, outstanding: 1, ... v: 3.0.17.2819

The failure is silent to the application layer (a connection reset), so pub/sub-driven features (cache invalidation, distributed-lock release notifications, etc.) just stop working after upgrading the client from 2.x to 3.x, with no server-side error.

Note: PUBLISH on a connection without an active subscription works fine under both protocols (:0 reply). The trigger is specifically PUBLISH while subscribed on the same connection.

This is related to (but distinct from) #437, which was closed as "not planned". #437 only covered the RESP2 case ("return an error instead of closing the socket") from a Node.js client. It did not cover the RESP3 conformance violation, and did not note that StackExchange.Redis 3.x makes this the default code path for .NET clients.

Steps to reproduce the bug

Start the latest Garnet:

docker run -d --name garnet -p 6379:6379 ghcr.io/microsoft/garnet:1.1.10

A. StackExchange.Redis 3.x (the real-world trigger)PackageReference Include="StackExchange.Redis" Version="3.0.17"

using StackExchange.Redis;

// SE.Redis 3.x negotiates RESP3 by default; the subscription is multiplexed
// onto the interactive connection, so PUBLISH runs on a subscribed connection.
var mux = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
var sub = mux.GetSubscriber();
var chan = RedisChannel.Literal("chan");

await sub.SubscribeAsync(chan, (_, v) => Console.WriteLine($"received: {v}"));
await sub.PublishAsync(chan, "hi");   // throws RedisConnectionException: SocketClosed ... last: PUBLISH

Forcing RESP2 makes it work (ConnectAsync("localhost:6379,protocol=resp2"), or options.Protocol = RedisProtocol.Resp2), because SE.Redis then uses a dedicated subscription connection and PUBLISH goes on a subscription-free connection.

B. Client-agnostic raw-RESP repro (no .NET needed)

import socket
def cmd(*p):
    o = f"*{len(p)}\r\n"
    for x in p: o += f"${len(x)}\r\n{x}\r\n"
    return o.encode()
s = socket.create_connection(("localhost", 6379)); s.settimeout(1.5)
s.sendall(cmd("HELLO", "3"));            print("HELLO    ->", s.recv(65536)[:32], "...")
s.sendall(cmd("SUBSCRIBE", "chan"));     print("SUBSCRIBE->", s.recv(65536))
s.sendall(cmd("PUBLISH", "chan", "hi"))
data = s.recv(65536)
print("PUBLISH  ->", repr(data) if data else ">>> SOCKET CLOSED (0 bytes) <<<")

Output against Garnet 1.1.10:

HELLO    -> b'%8\r\n$6\r\nserver\r\n$5\r\nredis...' ...
SUBSCRIBE-> b'*3\r\n$9\r\nsubscribe\r\n$4\r\nchan\r\n:1\r\n'
PUBLISH  -> >>> SOCKET CLOSED (0 bytes) <<<

(Same with HELLO 2. With no prior SUBSCRIBE, PUBLISH correctly returns :0.)

Expected behavior

  • RESP3 (HELLO 3): PUBLISH on a subscribed connection returns its integer reply (:N = number of receivers), matching the RESP3 spec and Redis 7.x behavior. The subscriber on that connection also receives the message.
  • RESP2 (HELLO 2): if PUBLISH-while-subscribed is disallowed, return the standard error (-ERR ... only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context) — as Redis does — rather than closing the socket.

In neither case should the server close the TCP connection.

Screenshots

N/A — console/protocol output included above.

Release version

1.1.10 (latest stable; also reproduced on 1.1.6). Client: StackExchange.Redis 3.0.17.

IDE

N/A — reproduced with dotnet CLI (.NET 10 SDK) and a raw socket script; not IDE-specific.

OS version

Reproduced on:

  • macOS (Darwin 25.5, Apple Silicon) — client host
  • Windows Server — client host
  • Linux (Garnet official Docker container ghcr.io/microsoft/garnet:1.1.10) — server

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions