redis: release the event loop when close() is called while subscribed
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 (
signalCodeisSIGTERM, notnull). - 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).