fix: remove stale data listener explicitly on socket error - #3209
Conversation
|
Hi, I’m Jit, a friendly security platform designed to help developers build secure applications from day zero with an MVS (Minimal viable security) mindset. In case there are security findings, they will be communicated to you as a comment inside the PR. Hope you’ll enjoy using Jit. Questions? Comments? Want to learn more? Get in touch with us. |
|
Hey, sorry I didn't have time to introduce a reliable test for this yet! That would be great if a maintainer could add a test or give some guidance. Thanks 🙏 |
|
@Chrisp1tv thanks for this PR, i will take a look |
nkaradzhov
left a comment
There was a problem hiding this comment.
@Chrisp1tv, thanks for the careful writeup — the race is real. Traced in current master: on a socket error, RedisSocket.#onSocketError emits 'error', the client's error handler flushes and calls resetDecoder(), but the old net socket is still referenced and keeps its data listener, so any buffered bytes delivered afterward reach the freshly-reset decoder and can be matched to a new command. Removing the data listener and destroying the old socket inside #onSocketError is the right fix at the right layer, and the this.#socket?.destroy() guard in the #connect catch is a correct companion, since #socket can now become undefined mid-initiator.
Two things needed before merge:
-
Please rebase onto current
master.#onSocketErrorhas since gainedpublish(CHANNELS.ERROR, …)before theemit('error')and awasReady-guardedpublish(CHANNELS.CONNECTION_CLOSED, …)after it. Re-integrate the socket-capture/null/remove-listeners/destroy sequence without dropping those publishes, keeping ordering intact (capture + null → publish ERROR → emit → remove data listeners + destroy → CONNECTION_CLOSED → reconnect decision). -
Add a regression test for the interleaving. A fake socket that emits a
datachunk after theerrorevent, with an assertion that the reset decoder receives no stale bytes and a subsequent command's reply is not corrupted, would lock this in. Happy to point you at the fake-socket helpers in the client test-utils if useful.
Once rebased with that test, this is good to merge.
…test Rebase the socket-capture/null/remove-listeners/destroy sequence onto the new #onSocketError shape, following the ordering requested in review: capture + null -> publish ERROR -> emit -> remove data listeners + destroy -> CONNECTION_CLOSED -> reconnect decision. Add a regression test that captures the real net.Socket via net.createConnection interception, emits 'error' then 'data' on it, and asserts no stale bytes reach the RedisSocket data listener. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Hi @nkaradzhov! Sorry for the delay, didn't see the notification. I handled your review, re-adding the fix and implementing a regression test. :) Thanks for the clear instructions 🙏 |
nkaradzhov
left a comment
There was a problem hiding this comment.
Thanks @Chrisp1tv, this is looking good!

Description
Fix a race condition in
RedisSocketwhere buffereddataevents from a broken socket could be emitted after a reconnect had already started, causing stale responses to be matched to new commands.This PR tries to provide a fix for the bug reported in:
I must give some credits to @nicklvsa for the reproduction script I've been using to test the issue and my fixes. Thanks for this 🙏
Root cause (written by Claude):
RedisSocketattaches a persistentdatalistener to each TCP socket it creates. When a socket error occurs,#onSocketErrorflushes the command queue and resets the RESP decoder — but the old socket was not destroyed immediately, leaving itsdatalistener alive. Any responses already buffered in the kernel for the old socket would then fire, get parsed by the reset decoder, and be matched against new commands in the queue. This causes silent data corruption:GET key1could resolve with the value ofkey2.This fix:
this.#socketbefore emittingerror, so the reference is atomically transferred to a local variable before any listener runssocket.removeAllListeners('data')beforesocket.destroy(), ensuring no buffereddataevents from the old socket reach the decoder after teardownsocket.destroy()before the reconnectChecklist
npm testpass with this change (including linting)?Note
Medium Risk
Touches core connection error/reconnect teardown in
RedisSocket; behavior change is narrow but affects all error-driven reconnect paths.Overview
Fixes a reconnect race where buffered
datafrom a failed TCP connection could still reach the RESP decoder and be paired with new commands after the client had already moved on.On socket error,
#onSocketErrornow captures and clearsthis.#socketimmediately, thenremoveAllListeners('data')anddestroy()on that old socket before any reconnect logic runs, so late kernel-buffered bytes cannot be forwarded throughRedisSocket'sdataevent.Adds an integration-style test that emits
errorthendataon the underlyingnet.Socketand asserts nothing is forwarded toRedisSocketlisteners.Reviewed by Cursor Bugbot for commit 4fbc686. Bugbot is set up for automated code reviews on this repo. Configure here.