Skip to content

Commit c1e9199

Browse files
authored
Merge pull request #844 from eynhaender/fix/chunking
Fix websocket body writes to frame as one multi-part message.
2 parents 2b0ee94 + 25362e9 commit c1e9199

6 files changed

Lines changed: 186 additions & 7 deletions

File tree

include/bitcoin/network/net/socket.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ class BCT_API socket
272272
void async_read_some(const asio::mutable_buffer& buffer,
273273
const count_handler& handler) NOEXCEPT;
274274
void async_write(const asio::const_buffer& buffer, bool binary,
275-
const count_handler& handler) NOEXCEPT;
275+
bool finish, const count_handler& handler) NOEXCEPT;
276276
void async_read_http(http::flat_buffer& buffer, http::request& request,
277277
const count_handler& handler) NOEXCEPT;
278278
void async_write_http(http::response&& response,

src/net/socket.cpp

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,12 @@ asio::ssl::socket& socket::get_ssl() NOEXCEPT
263263
// ----------------------------------------------------------------------------
264264
// protected
265265

266-
// write message in a single frame (ws) or unframed fixed size bytes (tcp).
266+
// write message frame (ws, finish closes the message) or unframed fixed
267+
// size bytes (tcp). Beast applies the binary/text setting only at the start
268+
// of a message, so it is harmless to pass it on every frame of a
269+
// multi-frame message (see boost::beast::websocket::stream::binary).
267270
void socket::async_write(const asio::const_buffer& buffer, bool binary,
268-
const count_handler& handler) NOEXCEPT
271+
bool finish, const count_handler& handler) NOEXCEPT
269272
{
270273
BC_ASSERT(stranded());
271274

@@ -275,7 +278,7 @@ void socket::async_write(const asio::const_buffer& buffer, bool binary,
275278
{
276279
VARIANT_DISPATCH_METHOD(get_ws(), binary(binary));
277280
VARIANT_DISPATCH_METHOD(get_ws(),
278-
async_write(buffer, std::bind(&socket::handle_async,
281+
async_write_some(finish, buffer, std::bind(&socket::handle_async,
279282
shared_from_this(), _1, _2, handler, "async_write")));
280283
}
281284
else

src/net/socket_body.cpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ void socket::do_body_write(boost_code ec, size_t total,
200200
out->more = buffer.value().second;
201201
const auto& data = buffer.value().first;
202202

203-
async_write(data, out->writer.binary(),
203+
// A ws message closes on the final chunk (finish = !more), so a body
204+
// that spans multiple get() passes is delivered as one multi-frame
205+
// message rather than one whole message per chunk.
206+
async_write(data, out->writer.binary(), !out->more,
204207
std::bind(&socket::handle_body_write,
205208
shared_from_this(), _1, _2, total, out, handler));
206209
}
@@ -275,7 +278,7 @@ void socket::do_body_notify(boost_code ec, size_t total,
275278
const auto& data = buffer.value().first;
276279

277280
// TODO: derive websocket binary/text from body type mapping.
278-
async_write(data, false,
281+
async_write(data, false, !out->more,
279282
std::bind(&socket::handle_body_notify,
280283
shared_from_this(), _1, _2, total, out, handler));
281284
}

src/net/socket_ws.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ void socket::do_ws_write(const asio::const_buffer& in, bool binary,
7070
const count_handler& handler) NOEXCEPT
7171
{
7272
BC_ASSERT(stranded());
73-
async_write(in, binary, handler);
73+
74+
// Single-shot write of an already-whole buffer, always closes the
75+
// message (finish = true).
76+
async_write(in, binary, true, handler);
7477
}
7578

7679
// WS (event).

test/messages/json_body_writer.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,63 @@ BOOST_AUTO_TEST_CASE(json_body_writer__get__simple_object__success_expected_no_m
9797
BOOST_REQUIRE(!buffer.get().second);
9898
}
9999

100+
// A size_hint smaller than the serialized payload forces writer.get() to be
101+
// called multiple times (more == true on all but the last pass). This is the
102+
// chunking that drives socket::async_write's finish flag (finish = !more)
103+
// for websocket body writes: the fix relies on every non-final chunk
104+
// reporting more == true and only the final chunk reporting more == false.
105+
// boost::json::serializer::read() fills the given buffer to capacity on
106+
// every pass but the last, so for this size_hint and payload the split is
107+
// deterministic: four 8-byte-or-fewer chunks.
108+
BOOST_AUTO_TEST_CASE(json_body_writer__get__size_hint_smaller_than_payload__four_chunks_reassemble_with_terminal_no_more)
109+
{
110+
const std::string_view expected1{ R"({"key":")" };
111+
const std::string_view expected2{ "xxxxxxxx" };
112+
const std::string_view expected3{ "xxxxxxxx" };
113+
const std::string_view expected4{ R"(xxxx"})" };
114+
const asio::const_buffer out1{ expected1.data(), expected1.size() };
115+
const asio::const_buffer out2{ expected2.data(), expected2.size() };
116+
const asio::const_buffer out3{ expected3.data(), expected3.size() };
117+
const asio::const_buffer out4{ expected4.data(), expected4.size() };
118+
119+
json::body<>::value_type body{};
120+
body.model = object{ { "key", std::string(20, 'x') } };
121+
body.size_hint = 8;
122+
json::body<>::writer writer(body);
123+
boost_code ec{};
124+
writer.init(ec);
125+
BOOST_REQUIRE(!ec);
126+
BOOST_REQUIRE(!writer.done());
127+
128+
const auto buffer1 = writer.get(ec);
129+
BOOST_REQUIRE(!ec);
130+
BOOST_REQUIRE(buffer1.has_value());
131+
BOOST_REQUIRE(buffer1.get().first == out1);
132+
BOOST_REQUIRE(buffer1.get().second);
133+
BOOST_REQUIRE(!writer.done());
134+
135+
const auto buffer2 = writer.get(ec);
136+
BOOST_REQUIRE(!ec);
137+
BOOST_REQUIRE(buffer2.has_value());
138+
BOOST_REQUIRE(buffer2.get().first == out2);
139+
BOOST_REQUIRE(buffer2.get().second);
140+
BOOST_REQUIRE(!writer.done());
141+
142+
const auto buffer3 = writer.get(ec);
143+
BOOST_REQUIRE(!ec);
144+
BOOST_REQUIRE(buffer3.has_value());
145+
BOOST_REQUIRE(buffer3.get().first == out3);
146+
BOOST_REQUIRE(buffer3.get().second);
147+
BOOST_REQUIRE(!writer.done());
148+
149+
const auto buffer4 = writer.get(ec);
150+
BOOST_REQUIRE(!ec);
151+
BOOST_REQUIRE(buffer4.has_value());
152+
BOOST_REQUIRE(buffer4.get().first == out4);
153+
BOOST_REQUIRE(!buffer4.get().second);
154+
BOOST_REQUIRE(writer.done());
155+
}
156+
100157
BOOST_AUTO_TEST_SUITE_END()
101158

102159
////#endif // HAVE_SLOW_TESTS

test/net/socket.cpp

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
*/
1919
#include "../test.hpp"
2020

21+
#include <future>
22+
2123
BOOST_AUTO_TEST_SUITE(socket_tests)
2224

2325
class socket_accessor
@@ -231,4 +233,115 @@ BOOST_AUTO_TEST_CASE(socket__write__disconnected__bad_stream)
231233
BOOST_REQUIRE(pool.join());
232234
}
233235

236+
// Regression test for a fix to socket::async_write: it used to write every
237+
// body_write() chunk as its own whole websocket message (finish always
238+
// true), splitting one logical response into N independent messages. The
239+
// fix passes finish = !out->more, so only the final chunk closes the
240+
// message. A real (non-library) websocket client is used as ground truth:
241+
// beast's blocking read() returns only once a full message (finish) is
242+
// received, so a single read() call assembling the entire multi-chunk body
243+
// proves the chunks were sent as one multi-frame message rather than as
244+
// several.
245+
BOOST_AUTO_TEST_CASE(socket__body_write__websocket_multiple_chunks__single_message)
246+
{
247+
using namespace std::chrono_literals;
248+
249+
const logger log{};
250+
threadpool pool(2);
251+
connector::parameters params{ .maximum_request = 1'000'000u };
252+
253+
// Bind a loopback acceptor on an ephemeral port.
254+
asio::strand accept_strand(pool.service().get_executor());
255+
asio::acceptor acceptor(accept_strand);
256+
boost_code ec{};
257+
const asio::endpoint bind_endpoint(asio::ipv4::loopback(), 0);
258+
259+
acceptor.open(bind_endpoint.protocol(), ec);
260+
BOOST_REQUIRE(!ec);
261+
acceptor.set_option(asio::reuse_address(true), ec);
262+
BOOST_REQUIRE(!ec);
263+
acceptor.bind(bind_endpoint, ec);
264+
BOOST_REQUIRE(!ec);
265+
acceptor.listen(1, ec);
266+
BOOST_REQUIRE(!ec);
267+
const auto port = acceptor.local_endpoint().port();
268+
269+
const auto server = std::make_shared<socket_accessor>(log, pool.service(), std::move(params));
270+
const auto buffer = std::make_shared<http::flat_buffer>();
271+
const auto request = std::make_shared<http::request>();
272+
273+
// Small size_hint relative to the payload forces writer.get() across
274+
// many passes, i.e. many socket::async_write calls for one logical body.
275+
json::body<>::value_type content{};
276+
content.model = boost::json::object{ { "key", std::string(4000, 'x') } };
277+
content.size_hint = 32;
278+
const auto expected = boost::json::serialize(content.model);
279+
280+
const auto response = std::make_shared<http::response>();
281+
response->result(boost::beast::http::status::ok);
282+
response->body() = std::move(content);
283+
284+
const auto upgrade_result = std::make_shared<std::promise<code>>();
285+
const auto write_result = std::make_shared<std::promise<code>>();
286+
auto upgrade_future = upgrade_result->get_future();
287+
auto write_future = write_result->get_future();
288+
289+
server->accept(acceptor,
290+
[=](const code& accept_ec) mutable
291+
{
292+
if (accept_ec)
293+
{
294+
upgrade_result->set_value(accept_ec);
295+
write_result->set_value(accept_ec);
296+
return;
297+
}
298+
299+
server->http_read(*buffer, *request,
300+
[=](const code& read_ec, size_t) mutable
301+
{
302+
upgrade_result->set_value(read_ec);
303+
if (read_ec != error::upgraded)
304+
{
305+
write_result->set_value(read_ec);
306+
return;
307+
}
308+
309+
server->body_write(std::move(*response),
310+
[=](const code& write_ec, size_t) NOEXCEPT
311+
{
312+
write_result->set_value(write_ec);
313+
});
314+
});
315+
});
316+
317+
// Real (blocking) websocket client, independent of the code under test.
318+
asio::context client_service;
319+
asio::socket raw(client_service);
320+
boost_code client_ec{};
321+
raw.connect({ asio::ipv4::loopback(), port }, client_ec);
322+
BOOST_REQUIRE(!client_ec);
323+
324+
ws::socket client(std::move(raw));
325+
client.handshake("127.0.0.1", "/", client_ec);
326+
BOOST_REQUIRE(!client_ec);
327+
328+
// One blocking read of a "complete message" must assemble every chunk.
329+
http::flat_buffer read_buffer{};
330+
client.read(read_buffer, client_ec);
331+
BOOST_REQUIRE(!client_ec);
332+
BOOST_REQUIRE(client.got_text());
333+
334+
const auto received = boost::beast::buffers_to_string(read_buffer.data());
335+
BOOST_REQUIRE_EQUAL(received, expected);
336+
337+
BOOST_REQUIRE(upgrade_future.wait_for(2s) == std::future_status::ready);
338+
BOOST_REQUIRE_EQUAL(upgrade_future.get(), error::upgraded);
339+
BOOST_REQUIRE(write_future.wait_for(2s) == std::future_status::ready);
340+
BOOST_REQUIRE_EQUAL(write_future.get(), error::success);
341+
342+
server->stop();
343+
pool.stop();
344+
BOOST_REQUIRE(pool.join());
345+
}
346+
234347
BOOST_AUTO_TEST_SUITE_END()

0 commit comments

Comments
 (0)