release the stale reply binding of a buffered push - #6788
Merged
SteffenDE merged 1 commit intoAug 10, 2026
Merged
Conversation
patric-vinicios
force-pushed
the
fix-buffered-push-binding-leak
branch
2 times, most recently
from
August 9, 2026 16:05
3ef782f to
1a85e2c
Compare
Push.startTimeout mints a new ref and registers a new chan_reply_*
binding on the channel, but never releases the binding for the previous
ref. A buffered push runs it twice, once from Channel.push when canPush
is false and again from Push.send once the join is acked, so every push
made while the channel is not joined leaves a binding behind. The server
only ever replies to the second ref, so that binding is never triggered:
it stays on channel.bindings for the lifetime of the channel, keeps the
push payload reachable through its callback closure, and is walked by
Channel.trigger on every incoming message.
Cancelling the previous binding cannot drop a real reply, because a
buffered push never reached the wire under the old ref: Push.send is the
only path that calls socket.push. On every other path into startTimeout
refEvent is already null and the call is a no-op.
The test restores the real makeRef. The existing describe("push") block
stubs it to a constant, which collides every reply binding on
chan_reply_1 so channel.off clears the leaked one along with the live
one.
Mechanism originally diagnosed by @xh4 in phoenixframework#4948.
patric-vinicios
force-pushed
the
fix-buffered-push-binding-leak
branch
from
August 9, 2026 16:08
1a85e2c to
facbcba
Compare
Member
|
Thank you! 🙌🏻 |
SteffenDE
pushed a commit
that referenced
this pull request
Aug 10, 2026
Push.startTimeout mints a new ref and registers a new chan_reply_*
binding on the channel, but never releases the binding for the previous
ref. A buffered push runs it twice, once from Channel.push when canPush
is false and again from Push.send once the join is acked, so every push
made while the channel is not joined leaves a binding behind. The server
only ever replies to the second ref, so that binding is never triggered:
it stays on channel.bindings for the lifetime of the channel, keeps the
push payload reachable through its callback closure, and is walked by
Channel.trigger on every incoming message.
Cancelling the previous binding cannot drop a real reply, because a
buffered push never reached the wire under the old ref: Push.send is the
only path that calls socket.push. On every other path into startTimeout
refEvent is already null and the call is a no-op.
The test restores the real makeRef. The existing describe("push") block
stubs it to a constant, which collides every reply binding on
chan_reply_1 so channel.off clears the leaked one along with the live
one.
Mechanism originally diagnosed by @xh4 in #4948.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
startTimeoutcan run twice for the same push, and the second run leaves the first one's binding behind.It happens whenever a push goes out on a channel that isn't joined yet.
Channel.pushcallsstartTimeout()and parks the push inpushBuffer;once the join is acked the buffer gets flushed through
Push.send, which callsstartTimeout()again. Each call mints a fresh ref and registers a newchan_reply_*binding, but nothing releases the one from the first call —cancelRefEventonly knows aboutthis.refEvent, and that's already been overwritten by then.So the first binding just sits there. The push never went out under that ref, so the server never replies to it, so it never fires and never cleans itself up. It stays on
channel.bindingsfor as long as the channel lives, and since the callback closes over thePush, the payload stays reachable with it.Channel.triggerwalks that array on every message that comes in.The window is narrow but it gets hit constantly — everything between
join()and the join ack, plus the entire time a socket is down. Which is exactly when pushes get buffered in the first place.Against a real server, 25 pushes during the join window plus another 25 across a reconnect leave 50 dead bindings, and all 50 payloads are still reachable after a forced GC. Both go to zero with this change. A push sent while the channel is joined gets collected either way, which acts as the control.
Dropping the old binding can't lose a real reply. A buffered push never hit the wire under the old ref, since
Push.sendis the only thing that callssocket.pushand it hasn't run yet. Every other path intostartTimeout(resend, the join push,leave, an ordinarypush) hasrefEventat null already, so the extra call does nothing.One thing about the test: it restores the real
makeRef. The existingdescribe("push")block stubs it to a constant, which lands every reply binding onchan_reply_1— and sincechannel.off(event)clears all bindings for an event, the leaked one gets swept up along with the live one. That's why the suite never caught it.@xh4 worked out the mechanism back in #4948. That issue got closed asking whether the regenerated ref actually caused a problem, and the numbers above are the answer to that.