redis: release the event loop when close() is called while subscribed

Closed
RO
2mo ago
+301-2

Fixes #33103.

Repro

With any Redis server running, close a RedisClient that is still in subscriber mode. The script prints its last line but the process never exits.

import { RedisClient } from "bun";
const url = process.env.REDIS_URL;
const publisher = new RedisClient(url);
const subscriber = new RedisClient(url);
await publisher.connect();
await subscriber.connect();

const received = Promise.withResolvers<string>();
await subscriber.subscribe("chan", m => received.resolve(m));
await publisher.publish("chan", "hello");
console.log("got:", await received.promise);

subscriber.close();
publisher.close();
console.log("closed both clients, process should exit now");
$ timeout 15 bun repro.ts; echo "exit $?"
got: hello
closed both clients, process should exit now
exit 124        # killed by timeout; never exits on its own

Uncommenting await subscriber.unsubscribe("chan") before close() makes it exit 0 immediately.

Cause

JSValkeyClient::update_poll_ref() decides whether to hold the event-loop poll ref (and a strong ref to the JS wrapper) from has_activity, which is true whenever the subscription callback map is non-empty:

let subs_deletable = self.client.get().flags.finalized
    || !self._subscription_ctx.get().has_subscriptions(&self.global_object).unwrap_or(false);
let has_activity = has_pending_commands || !subs_deletable || self.client.get().flags.is_reconnecting;

A manual close() (disconnect() -> close() -> on_valkey_close()) sets is_manually_closed, closes the socket, and fails pending commands, but never clears the subscription callback map. So has_subscriptions() stays true, has_activity stays true, and the poll ref plus the strong this_value are held forever. The map is only emptied by an explicit unsubscribe(), which is why the workaround is to unsubscribe before closing.

Fix

Treat a manually-closed client the same as a finalized one when deciding whether subscriptions still count as activity. Once is_manually_closed is set the connection is terminally dead (the socket is gone and no reconnect happens), so its subscription handlers can no longer deliver a message and must stop pinning the event loop. is_manually_closed is reset in do_connect(), so a client that reconnects re-arms its keep-alive normally.

Verification

New test in test/js/valkey/valkey-gc.test.ts (hermetic: an in-process RESP3 mock server, no Docker). It spawns a subscriber subprocess that connects, subscribes, and close()s, with the mock server in the parent process so the subprocess's exit depends only on the client releasing its refs.

  • Fails before the fix: the subprocess hangs and is killed by the spawn timeout (signalCode is SIGTERM, not null).
  • Passes after the fix: the subprocess exits 0 on its own.

Manual checks against a real Redis with the debug build:

  • subscribe -> publish -> receive still works, and close() while subscribed now exits 0.
  • a subscribed client that does not close() still keeps the process alive (no over-eager ref drop).