Skip to content

Commit 1a67c61

Browse files
SteffenDEjosevalim
andcommitted
prevent unexpected memory usage on nd-json body splitting
Co-authored-by: José Valim <jose.valim@gmail.com>
1 parent 8ca76a2 commit 1a67c61

4 files changed

Lines changed: 127 additions & 23 deletions

File tree

assets/js/phoenix/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const phxWindow = typeof window !== "undefined" ? window : null
33
export const global = globalSelf || phxWindow || globalThis
44
export const DEFAULT_VSN = "2.0.0"
55
export const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3}
6+
export const MAX_LONGPOLL_BATCH_SIZE = 100;
67
export const DEFAULT_TIMEOUT = 10000
78
export const WS_CLOSE_NORMAL = 1000
89
export const CHANNEL_STATES = {

assets/js/phoenix/longpoll.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
SOCKET_STATES,
33
TRANSPORTS,
4-
AUTH_TOKEN_PREFIX
4+
AUTH_TOKEN_PREFIX,
5+
MAX_LONGPOLL_BATCH_SIZE
56
} from "./constants"
67

78
import Ajax from "./ajax"
@@ -149,16 +150,22 @@ export default class LongPoll {
149150
}
150151
}
151152

152-
batchSend(messages){
153+
batchSend(messages, offset = 0){
153154
this.awaitingBatchAck = true
154-
this.ajax("POST", {"Content-Type": "application/x-ndjson"}, messages.join("\n"), () => this.onerror("timeout"), resp => {
155-
this.awaitingBatchAck = false
155+
const next = offset + MAX_LONGPOLL_BATCH_SIZE
156+
const batch = messages.slice(offset, next)
157+
this.ajax("POST", {"Content-Type": "application/x-ndjson"}, batch.join("\n"), () => this.onerror("timeout"), resp => {
156158
if(!resp || resp.status !== 200){
159+
this.awaitingBatchAck = false
157160
this.onerror(resp && resp.status)
158161
this.closeAndRetry(1011, "internal server error", false)
162+
} else if(next < messages.length){
163+
this.batchSend(messages, next)
159164
} else if(this.batchBuffer.length > 0){
160165
this.batchSend(this.batchBuffer)
161166
this.batchBuffer = []
167+
} else {
168+
this.awaitingBatchAck = false
162169
}
163170
})
164171
}

assets/test/longpoll_test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,101 @@ describe("LongPoll", () => {
158158
expect.any(Function)
159159
)
160160
})
161+
162+
it("coalesces rapid send() calls and buffers sends made during an in-flight batch", () => {
163+
jest.useFakeTimers()
164+
try {
165+
const longpoll = new LongPoll("http://localhost/socket/longpoll", undefined)
166+
longpoll.timeout = 1000
167+
// suppress the initial poll() that the constructor schedules via setTimeout(0)
168+
longpoll.poll = jest.fn()
169+
170+
const calls = []
171+
Ajax.request.mockImplementation((method, url, headers, body, timeout, ontimeout, callback) => {
172+
calls.push({method, body, callback})
173+
return {abort: jest.fn()}
174+
})
175+
176+
// Three sends in the same tick should collapse into one currentBatch
177+
longpoll.send("a")
178+
longpoll.send("b")
179+
longpoll.send("c")
180+
181+
expect(calls).toHaveLength(0)
182+
expect(longpoll.currentBatch).toEqual(["a", "b", "c"])
183+
184+
// Flush the setTimeout(0) — currentBatch becomes one POST
185+
jest.runOnlyPendingTimers()
186+
187+
expect(calls).toHaveLength(1)
188+
expect(calls[0].method).toBe("POST")
189+
expect(calls[0].body).toBe("a\nb\nc")
190+
expect(longpoll.currentBatch).toBeNull()
191+
expect(longpoll.awaitingBatchAck).toBe(true)
192+
193+
// Sends during in-flight ack go to batchBuffer, not a new request
194+
longpoll.send("d")
195+
longpoll.send("e")
196+
expect(calls).toHaveLength(1)
197+
expect(longpoll.batchBuffer).toEqual(["d", "e"])
198+
199+
// Ack the first batch — the buffered sends should be flushed as the next POST
200+
calls[0].callback({status: 200})
201+
202+
expect(calls).toHaveLength(2)
203+
expect(calls[1].body).toBe("d\ne")
204+
expect(longpoll.batchBuffer).toEqual([])
205+
expect(longpoll.awaitingBatchAck).toBe(true)
206+
207+
// Ack the buffered batch — nothing left to send
208+
calls[1].callback({status: 200})
209+
expect(calls).toHaveLength(2)
210+
expect(longpoll.awaitingBatchAck).toBe(false)
211+
} finally {
212+
jest.useRealTimers()
213+
}
214+
})
215+
216+
it("splits 150 rapid send() calls into two requests in order", () => {
217+
jest.useFakeTimers()
218+
try {
219+
const longpoll = new LongPoll("http://localhost/socket/longpoll", undefined)
220+
longpoll.timeout = 1000
221+
longpoll.poll = jest.fn()
222+
223+
const calls = []
224+
Ajax.request.mockImplementation((method, url, headers, body, timeout, ontimeout, callback) => {
225+
calls.push({body, callback})
226+
return {abort: jest.fn()}
227+
})
228+
229+
for(let i = 0; i < 150; i++){ longpoll.send(`m${i}`) }
230+
231+
// Flush the setTimeout(0) so batchSend runs on the full 150-entry batch
232+
jest.runOnlyPendingTimers()
233+
234+
expect(calls).toHaveLength(1)
235+
const firstLines = calls[0].body.split("\n")
236+
expect(firstLines).toHaveLength(100)
237+
expect(firstLines[0]).toBe("m0")
238+
expect(firstLines[99]).toBe("m99")
239+
240+
// Ack the first chunk — batchSend should recurse with the remaining 50
241+
calls[0].callback({status: 200})
242+
243+
expect(calls).toHaveLength(2)
244+
const secondLines = calls[1].body.split("\n")
245+
expect(secondLines).toHaveLength(50)
246+
expect(secondLines[0]).toBe("m100")
247+
expect(secondLines[49]).toBe("m149")
248+
249+
calls[1].callback({status: 200})
250+
expect(calls).toHaveLength(2)
251+
expect(longpoll.awaitingBatchAck).toBe(false)
252+
} finally {
253+
jest.useRealTimers()
254+
}
255+
})
161256
})
162257
})
163258

lib/phoenix/transports/long_poll.ex

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ defmodule Phoenix.Transports.LongPoll do
22
@moduledoc false
33
@behaviour Plug
44

5-
# 10MB
5+
# The maximum is 10MB but read_body will cap the whole request at ~8MB,
6+
# so this acts as a secondary protection mechanism.
67
@max_base64_size 10_000_000
8+
# TODO: enforce batch size on the server in the next release
9+
# @max_poll_batch_size 100
710
@connect_info_opts [:check_csrf]
811

912
import Plug.Conn
@@ -78,30 +81,28 @@ defmodule Phoenix.Transports.LongPoll do
7881
defp publish(conn, server_ref, endpoint, opts) do
7982
case read_body(conn, []) do
8083
{:ok, body, conn} ->
81-
# we need to match on both v1 and v2 protocol, as well as wrap for backwards compat
82-
batch =
84+
# We need to match on both v1 and v2 protocol, as well as wrap for backwards compat
85+
status =
8386
case get_req_header(conn, "content-type") do
8487
["application/x-ndjson"] ->
8588
body
86-
|> String.split(["\n", "\r\n"])
87-
|> Enum.map(fn
88-
"[" <> _ = txt -> {txt, :text}
89-
base64 -> {safe_decode64!(base64), :binary}
89+
|> String.splitter(["\n", "\r\n"])
90+
# |> Stream.take(@max_poll_batch_size)
91+
|> Enum.find(fn part ->
92+
msg =
93+
case part do
94+
"[" <> _ = txt -> {txt, :text}
95+
base64 -> {safe_decode64!(base64), :binary}
96+
end
97+
98+
transport_dispatch(endpoint, server_ref, msg, opts)
9099
end)
91100

92101
_ ->
93-
[{body, :text}]
102+
transport_dispatch(endpoint, server_ref, {body, :text}, opts)
94103
end
95104

96-
{conn, status} =
97-
Enum.reduce_while(batch, {conn, nil}, fn msg, {conn, _status} ->
98-
case transport_dispatch(endpoint, server_ref, msg, opts) do
99-
:ok -> {:cont, {conn, :ok}}
100-
:request_timeout = timeout -> {:halt, {conn, timeout}}
101-
end
102-
end)
103-
104-
conn |> put_status(status) |> status_json()
105+
conn |> put_status(status || :ok) |> status_json()
105106

106107
_ ->
107108
raise Plug.BadRequestError
@@ -121,8 +122,8 @@ defmodule Phoenix.Transports.LongPoll do
121122
broadcast_from!(endpoint, server_ref, {:dispatch, client_ref(server_ref), body, ref})
122123

123124
receive do
124-
{:ok, ^ref} -> :ok
125-
{:error, ^ref} -> :ok
125+
{:ok, ^ref} -> nil
126+
{:error, ^ref} -> nil
126127
after
127128
opts[:window_ms] -> :request_timeout
128129
end

0 commit comments

Comments
 (0)