diff --git a/cmake/OssiaDeps.cmake b/cmake/OssiaDeps.cmake index 3b5f3de885f..f086aaf90dc 100644 --- a/cmake/OssiaDeps.cmake +++ b/cmake/OssiaDeps.cmake @@ -24,7 +24,6 @@ if(Git_FOUND AND OSSIA_SUBMODULE_AUTOUPDATE) tuplet unordered_dense verdigris - websocketpp whereami ../cmake/cmake-modules ios-cmake @@ -107,7 +106,6 @@ include(deps/spdlog) include(deps/tuplet) include(deps/unordered_dense) include(deps/verdigris) -include(deps/websocketpp) if(OSSIA_PROTOCOL_COAP) include(deps/coap) diff --git a/cmake/deps/abseil.cmake b/cmake/deps/abseil.cmake index bfa14ebebee..07d927c06d6 100644 --- a/cmake/deps/abseil.cmake +++ b/cmake/deps/abseil.cmake @@ -7,6 +7,17 @@ endif() # add_subdirectory-based dependencies (rubberband, libremidi, ...). if(NOT TARGET absl::strings) block() + # A parent project (e.g. score) may have CMAKE_INCLUDE_CURRENT_DIR ON. + # That would add each Abseil target's own source/binary dir to its include + # path, and `absl/time/time.h` would then shadow the system , + # breaking libstdc++'s /. Keep it off for Abseil's build. + # (Scoped to this block(), so the parent setting is untouched afterwards.) + set(CMAKE_INCLUDE_CURRENT_DIR OFF) + # A parent project (e.g. score) may build with CMAKE_UNITY_BUILD ON. Abseil's + # cctz sources are not unity-safe: time_zone_posix.cc and time_zone_fixed.cc + # each define a namespace-scope `kDigits`, so a merged TU fails with a + # redefinition. Build Abseil one TU at a time. (Scoped to this block().) + set(CMAKE_UNITY_BUILD OFF) set(BUILD_SHARED_LIBS 0) set(BUILD_TESTING 0) set(ABSL_BUILD_TESTING OFF CACHE INTERNAL "") diff --git a/examples/Web/DoubleWSServer.cpp b/examples/Web/DoubleWSServer.cpp index 77d60460da1..2b01efb0d9d 100644 --- a/examples/Web/DoubleWSServer.cpp +++ b/examples/Web/DoubleWSServer.cpp @@ -1,33 +1,31 @@ #include +#include #include +#include #include #include #include +#include #include #include #include #include -#include -#include -#include +#include + +#include +#include +#include #include +#include // double websocket server test // this example create a simple oscquery server that exposes its parameter through port 5678 // you can get the namespace in a browser with : ws://127.0.0.1:5678 // and it also creates a second web socket server to stream a webcam in JPEG on port 9003 // to see it, open the file double_ws_server-test.html (next to this one) in a browser -// KNOWN issue : as soon as you start the second ws server, the first one doesn't respond anymore - -typedef websocketpp::server server; - -using websocketpp::connection_hdl; -using websocketpp::lib::bind; -using websocketpp::lib::placeholders::_1; -using websocketpp::lib::placeholders::_2; using namespace std; using namespace ossia; @@ -35,19 +33,21 @@ using namespace ossia; class broadcast_server { public: - broadcast_server() + explicit broadcast_server(ossia::net::network_context_ptr ctx) + : m_ctx{std::move(ctx)} + , m_server{m_ctx} { - m_server.init_asio(); - - m_server.set_open_handler(bind(&broadcast_server::on_open, this, ::_1)); - m_server.set_close_handler(bind(&broadcast_server::on_close, this, ::_1)); - m_server.set_message_handler(bind(&broadcast_server::on_message, this, ::_1, ::_2)); - - m_server.clear_access_channels( - websocketpp::log::alevel::frame_header - | websocketpp::log::alevel::frame_payload); + using namespace ossia::net; + m_server.set_open_handler( + [this](ws_connection_handle hdl) { m_connections.insert(hdl); }); + m_server.set_close_handler( + [this](ws_connection_handle hdl) { m_connections.erase(hdl); }); + m_server.set_message_handler( + [](const ws_connection_handle&, ws_opcode, const std::string&) { + return server_reply{}; + }); - m_cap_thread = std::thread([&]() { + m_cap_thread = std::thread([this]() { std::vector compression_params; compression_params.push_back(cv::IMWRITE_JPEG_QUALITY); compression_params.push_back(20); @@ -68,45 +68,45 @@ class broadcast_server if(cv::imencode(".jpg", small, jpeg_buf, compression_params)) { std::string encoded - = websocketpp::base64_encode(jpeg_buf.data(), jpeg_buf.size()); - - con_list::iterator it; - for(it = m_connections.begin(); it != m_connections.end(); ++it) - { - m_server.send(*it, encoded, websocketpp::frame::opcode::text); - } + = ossia::base64_encode(jpeg_buf.data(), jpeg_buf.size()); + + // The connection list and the actual writes are handled on the + // server's io_context thread so we don't race with the strand + // that runs accept / read / close handlers. + boost::asio::post(m_ctx->context, [this, msg = std::move(encoded)] { + for(auto& hdl : m_connections) + m_server.send_message(hdl, msg); + }); } - usleep(30000); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); } } }); } - void on_open(connection_hdl hdl) { m_connections.insert(hdl); } - - void on_close(connection_hdl hdl) { m_connections.erase(hdl); } - - void on_message(connection_hdl hdl, server::message_ptr msg) + ~broadcast_server() { - //for (auto it : m_connections) { - // m_server.send(it,msg); - // } + m_quit = true; + if(m_cap_thread.joinable()) + m_cap_thread.join(); } void run(uint16_t port) { m_server.listen(port); - m_server.start_accept(); m_server.run(); } private: - typedef std::set> con_list; + using con_list = std::set< + ossia::net::ws_connection_handle, + std::owner_less>; - server m_server; + ossia::net::network_context_ptr m_ctx; + ossia::net::websocket_server_beast m_server; con_list m_connections; - bool m_quit = false; + std::atomic_bool m_quit{false}; std::thread m_cap_thread; }; @@ -151,10 +151,7 @@ int main() }); push_thread.detach(); - broadcast_server server; - // comment the following to make ossia ws server work again + auto ctx = std::make_shared(); + broadcast_server server{ctx}; server.run(9003); - - while(true) - ; } diff --git a/examples/Web/JpegStreamer.cpp b/examples/Web/JpegStreamer.cpp index 8c817fff84f..26dd75e8cf5 100644 --- a/examples/Web/JpegStreamer.cpp +++ b/examples/Web/JpegStreamer.cpp @@ -1,10 +1,10 @@ +#include #include #include #include #include #include -#include int main(int, char**) { @@ -37,7 +37,7 @@ int main(int, char**) if(cv::imencode(".jpg", small, jpeg_buf, compression_params)) { std::string encoded - = websocketpp::base64_encode(jpeg_buf.data(), jpeg_buf.size()); + = ossia::base64_encode(jpeg_buf.data(), jpeg_buf.size()); param->push_value(encoded); } using namespace std::chrono_literals; diff --git a/src/ossia-max/CMakeLists.txt b/src/ossia-max/CMakeLists.txt index 6accb48420a..6f014ceaf32 100644 --- a/src/ossia-max/CMakeLists.txt +++ b/src/ossia-max/CMakeLists.txt @@ -106,7 +106,6 @@ ossia_set_visibility(${PROJECT_NAME}) target_link_libraries(${PROJECT_NAME} PRIVATE ossia $:${MAXSDK_API_LIBRARY}>> $ - $ ) generate_export_header(${PROJECT_NAME}) diff --git a/src/ossia-qt/protocols/qml_ws_outbound_socket.hpp b/src/ossia-qt/protocols/qml_ws_outbound_socket.hpp index 8f4d6812008..2b6102209b5 100644 --- a/src/ossia-qt/protocols/qml_ws_outbound_socket.hpp +++ b/src/ossia-qt/protocols/qml_ws_outbound_socket.hpp @@ -1,7 +1,8 @@ #pragma once #include #include -#include +#include +#include #include @@ -25,7 +26,7 @@ class qml_websocket_outbound_socket struct state { std::string url; - std::unique_ptr client; + std::unique_ptr client; std::atomic_bool alive{true}; }; @@ -50,13 +51,14 @@ class qml_websocket_outbound_socket m_state->url = "ws://" + conf.host + ":" + std::to_string(conf.port); // FIXME wss auto st = m_state; auto self = QPointer{this}; - m_state->client = std::make_unique( - ctx, [st, self](auto hdl, auto opcode, const std::string& msg) { + m_state->client = std::make_unique( + ctx, ossia::net::ws_client_message_handler{ + [st, self](const ossia::net::ws_connection_handle&, ossia::net::ws_opcode opcode, std::string& msg) { if(!st->alive) return; if(auto* ptr = self.get()) - ptr->on_message(hdl, opcode, msg); - }); + ptr->on_message(opcode, msg); + }}); if(onOpen.isCallable()) m_state->client->on_open.connect<&qml_websocket_outbound_socket::on_open>(this); @@ -68,13 +70,13 @@ class qml_websocket_outbound_socket m_state->client->connect(m_state->url); } - void on_message(auto hdl, auto opcode, const std::string& msg) + void on_message(ossia::net::ws_opcode opcode, const std::string& msg) { - if(opcode == websocketpp::frame::opcode::text && onTextMessage.isCallable()) + if(opcode == ossia::net::ws_opcode::text && onTextMessage.isCallable()) { onTextMessage.call({QString::fromStdString(msg)}); } - else if(opcode == websocketpp::frame::opcode::binary && onBinaryMessage.isCallable()) + else if(opcode == ossia::net::ws_opcode::binary && onBinaryMessage.isCallable()) { onBinaryMessage.call( {qjsEngine(this)->toScriptValue(QByteArray(msg.data(), msg.size()))}); @@ -107,12 +109,8 @@ class qml_websocket_outbound_socket if(!m_state) return; auto st = m_state; - boost::asio::dispatch( - st->client->context(), - [st, msg = message.toStdString()] { - if(st->alive) - st->client->send_message(msg); - }); + if(st->alive) + st->client->send_message(message.toStdString()); } W_SLOT(write) @@ -121,12 +119,8 @@ class qml_websocket_outbound_socket if(!m_state) return; auto st = m_state; - boost::asio::dispatch( - st->client->context(), - [st, buf = std::string(buffer.data(), buffer.size())] { - if(st->alive) - st->client->send_binary_message(buf); - }); + if(st->alive) + st->client->send_binary_message(std::string_view(buffer.data(), buffer.size())); } W_SLOT(writeBinary) @@ -140,10 +134,6 @@ class qml_websocket_outbound_socket } W_SLOT(close) - // FIXME - // void osc(QByteArray address, QJSValueList values) { this->send_osc(address, values); } - // W_SLOT(osc) - QJSValue onOpen; QJSValue onClose; QJSValue onError; diff --git a/src/ossia/detail/base64.hpp b/src/ossia/detail/base64.hpp new file mode 100644 index 00000000000..c418974d94b --- /dev/null +++ b/src/ossia/detail/base64.hpp @@ -0,0 +1,162 @@ +#pragma once +/* + base64.cpp and base64.h + + Copyright (C) 2004-2008 René Nyffenegger + + This source code is provided 'as-is', without any express or implied + warranty. In no event will the author be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this source code must not be misrepresented; you must not + claim that you wrote the original source code. If you use this source code + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original source code. + + 3. This notice may not be removed or altered from any source distribution. + + René Nyffenegger rene.nyffenegger@adp-gmbh.ch + + Repackaged as a standalone header for ossia. +*/ + +#include +#include + +namespace ossia +{ + +inline std::string base64_encode(unsigned char const* input, size_t len) +{ + static constexpr char base64_chars[] + = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + + std::string ret; + int i = 0; + unsigned char char_array_3[3]; + unsigned char char_array_4[4]; + + while(len--) + { + char_array_3[i++] = *(input++); + if(i == 3) + { + char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; + char_array_4[1] + = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); + char_array_4[2] + = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); + char_array_4[3] = char_array_3[2] & 0x3f; + + for(i = 0; i < 4; i++) + ret += base64_chars[char_array_4[i]]; + i = 0; + } + } + + if(i) + { + for(int j = i; j < 3; j++) + char_array_3[j] = '\0'; + + char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; + char_array_4[1] + = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); + char_array_4[2] + = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); + char_array_4[3] = char_array_3[2] & 0x3f; + + for(int j = 0; j < i + 1; j++) + ret += base64_chars[char_array_4[j]]; + + while(i++ < 3) + ret += '='; + } + + return ret; +} + +inline std::string base64_encode(std::string const& input) +{ + return base64_encode( + reinterpret_cast(input.data()), input.size()); +} + +inline std::string base64_decode(std::string const& input) +{ + static constexpr char base64_chars[] + = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + + auto is_base64 = [](unsigned char c) -> bool { + return (c == 43 || (c >= 47 && c <= 57) || (c >= 65 && c <= 90) + || (c >= 97 && c <= 122)); + }; + + size_t in_len = input.size(); + int i = 0; + int in_ = 0; + unsigned char char_array_4[4], char_array_3[3]; + std::string ret; + + while(in_len-- && (input[in_] != '=') && is_base64(input[in_])) + { + char_array_4[i++] = input[in_]; + in_++; + if(i == 4) + { + for(i = 0; i < 4; i++) + { + const char* p = base64_chars; + while(*p && *p != char_array_4[i]) + ++p; + char_array_4[i] = static_cast(p - base64_chars); + } + + char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); + char_array_3[1] + = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); + char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; + + for(i = 0; i < 3; i++) + ret += char_array_3[i]; + i = 0; + } + } + + if(i) + { + for(int j = i; j < 4; j++) + char_array_4[j] = 0; + + for(int j = 0; j < 4; j++) + { + const char* p = base64_chars; + while(*p && *p != char_array_4[j]) + ++p; + char_array_4[j] = static_cast(p - base64_chars); + } + + char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); + char_array_3[1] + = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); + char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; + + for(int j = 0; j < i - 1; j++) + ret += static_cast(char_array_3[j]); + } + + return ret; +} + +} // namespace ossia diff --git a/src/ossia/network/common/websocket_log_sink.hpp b/src/ossia/network/common/websocket_log_sink.hpp index 47fa84dfc4a..a7d15990937 100644 --- a/src/ossia/network/common/websocket_log_sink.hpp +++ b/src/ossia/network/common/websocket_log_sink.hpp @@ -2,7 +2,7 @@ #include #include #include -#include +#include #include #include @@ -16,7 +16,7 @@ namespace ossia struct websocket_threaded_connection { websocket_threaded_connection(const std::string& ip) - : socket([](auto&&...) {}) + : socket(ossia::net::ws_client_message_handler{[](auto&&...) {}}) { running = true; thread = std::thread([this, ip] { @@ -37,10 +37,6 @@ struct websocket_threaded_connection } log->critical("Logger stopping."); } - catch(const websocketpp::exception& e) - { - log->critical("Logger error: ", e.what()); - } catch(const std::exception& e) { log->critical("Logger error: ", e.what()); @@ -55,14 +51,13 @@ struct websocket_threaded_connection ~websocket_threaded_connection() { running = false; - if(!socket.after_connect()) - std::this_thread::sleep_for(std::chrono::milliseconds(500)); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); socket.stop(); if(thread.joinable()) thread.join(); } - ossia::net::websocket_client socket; + ossia::net::websocket_client_beast socket; std::atomic_bool running{}; std::thread thread; }; diff --git a/src/ossia/network/http/http_client.hpp b/src/ossia/network/http/http_client.hpp index eb0fbebf604..1668f16a9e4 100644 --- a/src/ossia/network/http/http_client.hpp +++ b/src/ossia/network/http/http_client.hpp @@ -1,52 +1,66 @@ #pragma once #include -#include #include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include namespace ossia::net { -using tcp = boost::asio::ip::tcp; +/// Asynchronous HTTP/1.1 GET-style request used by the OSCQuery mirror and +/// the QML HTTP protocol. +/// +/// This is a thin wrapper around boost::beast::http::async_* that preserves +/// the historical Fun/Err callable interface used by callers: +/// - Fun::reserve_expect: static constexpr buffer size hint (no longer +/// load-bearing — Beast manages its own buffer — but kept for ABI +/// compatibility) +/// - Fun(*this, body): success callback, body is passed as std::string +/// - Err(*this): error callback +/// +/// Typical use: +/// auto req = std::make_shared>( +/// F{}, E{}, ctx, "example.com", "/some/path"); +/// req->resolve("example.com", "80"); template -class http_get_request : public std::enable_shared_from_this> +class http_get_request + : public std::enable_shared_from_this> { - fmt::memory_buffer m_request; - public: using std::enable_shared_from_this>::shared_from_this; + http_get_request( Fun f, Err err, boost::asio::io_context& ctx, const std::string& server, const std::string& path, std::string_view verb = "GET") - : m_resolver(ctx) - , m_socket(ctx) + : m_resolver{boost::asio::make_strand(ctx)} + , m_stream{boost::asio::make_strand(ctx)} + , m_host{server} , m_fun{std::move(f)} , m_err{std::move(err)} { - m_request.reserve(100 + server.size() + path.size()); - m_response.prepare(Fun::reserve_expect); - fmt::format_to(fmt::appender(m_request), "{} ", verb); - // Technically other characters should be encoded... but - // they aren't legal in OSC address patterns. - for(auto c : path) - if(c != ' ') - fmt::format_to(fmt::appender(m_request), "{}", c); - else - fmt::format_to(fmt::appender(m_request), "%20"); - - fmt::format_to( - fmt::appender(m_request), - " HTTP/1.1\r\n" - "Host: {}\r\n" - "Accept: */*\r\n" - "Connection: close\r\n\r\n", - server); + namespace http = boost::beast::http; + + auto v = http::string_to_verb(std::string{verb}); + if(v == http::verb::unknown) + v = http::verb::get; + m_req.method(v); + m_req.version(11); + m_req.target(percent_encode_spaces(path)); + m_req.set(http::field::host, server); + m_req.set(http::field::accept, "*/*"); + m_req.set(http::field::user_agent, "ossia"); + m_req.set(http::field::connection, "close"); } void resolve(const std::string& server, const std::string& port) @@ -54,195 +68,133 @@ class http_get_request : public std::enable_shared_from_thisshared_from_this()]( - const boost::system::error_code& err, - const tcp::resolver::results_type& endpoints) { - self->handle_resolve(err, endpoints); - }); + const boost::beast::error_code& ec, + const boost::asio::ip::tcp::resolver::results_type& results) { + self->on_resolve(ec, results); + }); } - void close() { m_socket.close(); } + void close() + { + boost::beast::error_code ec; + m_stream.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + m_stream.socket().close(ec); + } private: - void handle_resolve( - const boost::system::error_code& err, const tcp::resolver::results_type& endpoints) + static std::string percent_encode_spaces(const std::string& path) { - if(!err) - { - boost::asio::async_connect( - m_socket, endpoints, - [self = shared_from_this()](const boost::system::error_code& err, auto&&...) { - self->handle_connect(err); - }); - } - else + // Technically other characters should be encoded too, but they aren't + // legal in OSC address patterns — preserve historical behaviour. + std::string out; + out.reserve(path.size()); + for(char c : path) { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this); + if(c == ' ') + out += "%20"; + else + out.push_back(c); } + return out; } - void handle_connect(const boost::system::error_code& err) + void on_resolve( + const boost::beast::error_code& ec, + const boost::asio::ip::tcp::resolver::results_type& results) { - if(!err) - { - boost::asio::const_buffer request(m_request.data(), m_request.size()); - boost::asio::async_write( - m_socket, request, - [self = shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_write_request(err, size); - }); - } - else + if(ec) { - ossia::logger().error("HTTP Error: {}", err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); m_err(*this); + return; } + m_stream.expires_after(std::chrono::seconds(30)); + m_stream.async_connect( + results, + [self = this->shared_from_this()]( + const boost::beast::error_code& ec, + const boost::asio::ip::tcp::resolver::results_type::endpoint_type&) { + self->on_connect(ec); + }); } - void handle_write_request(const boost::system::error_code& err, std::size_t size) + void on_connect(const boost::beast::error_code& ec) { - if(!err) - { - boost::asio::async_read_until( - m_socket, m_response, "\r\n", - [self = shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_status_line(err, size); - }); - } - else + if(ec) { - ossia::logger().error("HTTP Error: {}", err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); m_err(*this); + return; } + m_stream.expires_after(std::chrono::seconds(30)); + boost::beast::http::async_write( + m_stream, m_req, + [self = this->shared_from_this()]( + const boost::beast::error_code& ec, std::size_t bytes) { + self->on_write(ec, bytes); + }); } - void handle_read_status_line(const boost::system::error_code& err, std::size_t size) + void on_write(const boost::beast::error_code& ec, std::size_t) { - if(!err || err == boost::asio::error::eof) - { - // Check that response is OK. - std::istream response_stream(&m_response); - std::string http_version; - response_stream >> http_version; - unsigned int status_code; - response_stream >> status_code; - std::string status_message; - std::getline(response_stream, status_message); - if(!response_stream || http_version.substr(0, 5) != "HTTP/") - { - ossia::logger().error("HTTP Error: Invalid response"); - return; - } - if(status_code != 200) - { - ossia::logger().error("HTTP Error: status code {}", status_code); - return; - } - - // Read the response headers, which are terminated by a blank line. - boost::asio::async_read_until( - m_socket, m_response, "\r\n\r\n", - [self = shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_headers(err, size); - }); - } - else + if(ec) { - ossia::logger().error("HTTP Error: {}", err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); m_err(*this); + return; } + m_stream.expires_after(std::chrono::seconds(30)); + // Use an explicit parser with a generous body limit: OSCQuery namespace + // dumps regularly exceed Beast's default 1 MiB cap. + m_parser.body_limit(256u * 1024u * 1024u); + boost::beast::http::async_read( + m_stream, m_buffer, m_parser, + [self = this->shared_from_this()]( + const boost::beast::error_code& ec, std::size_t bytes) { + self->on_read(ec, bytes); + }); } - void handle_read_headers(const boost::system::error_code& err, std::size_t size) + void on_read(const boost::beast::error_code& ec, std::size_t) { - if(!err || err == boost::asio::error::eof) - { - // Process the response headers. - std::istream response_stream(&m_response); - std::string header; - while(std::getline(response_stream, header) && header != "\r") - { - if(header.starts_with("Content-Length: ")) - { - std::string_view sz(header.begin() + strlen("Content-Length: "), header.end()); - if(auto num = ossia::parse_relax(sz)) - { - m_contentLength = *num; - } - else - { - ossia::logger().error("Invalid HTTP Content-length: {}", sz); - return; - } - } - } - - if(m_contentLength > 0) - { - if(m_contentLength == m_response.size()) - { - finish_read(boost::asio::error::eof, size); - } - else - { - boost::asio::async_read( - m_socket, m_response, boost::asio::transfer_exactly(m_contentLength - m_response.size()), - [self = shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_content(err, size); - }); - } - } - else - { - // Start reading remaining data until EOF. - boost::asio::async_read( - m_socket, m_response, boost::asio::transfer_all(), - [self = shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_content(err, size); - }); - } - } - else + if(ec && ec != boost::beast::http::error::end_of_stream + && ec != boost::asio::error::eof) { - ossia::logger().error("HTTP Error: {}", err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); m_err(*this); + return; } - } - void handle_read_content(const boost::system::error_code& err, std::size_t size) - { - if(!err || err == boost::asio::error::eof) - finish_read(err, size); - else + auto& res = m_parser.get(); + auto status = res.result_int(); + if(status != 200) { - ossia::logger().error("HTTP Error: {}", err.message()); + ossia::logger().error("HTTP Error: status code {}", (int)status); m_err(*this); + close(); + return; } - } - void finish_read(const boost::system::error_code& err, std::size_t size) - { - const auto& dat = m_response.data(); - auto begin = boost::asio::buffers_begin(dat); - auto end = boost::asio::buffers_end(dat); - auto sz = end - begin; - std::string str; - str.reserve(sz + 16); // for RapidJSON simd parsing which reads past bounds - str.assign(begin, end); - m_fun(*this, str); + // Reserve a small tail so RapidJSON's SIMD parser, which reads past + // the end of the buffer, doesn't overrun. The previous implementation + // baked the same +16 reservation in. + auto& body = res.body(); + std::string out; + out.reserve(body.size() + 16); + out.assign(body.begin(), body.end()); + + m_fun(*this, out); close(); } - tcp::resolver m_resolver; - tcp::socket m_socket; - boost::asio::streambuf m_response; - int m_contentLength{-1}; + boost::asio::ip::tcp::resolver m_resolver; + boost::beast::tcp_stream m_stream; + boost::beast::flat_buffer m_buffer; + boost::beast::http::request m_req; + boost::beast::http::response_parser m_parser; + std::string m_host; Fun m_fun; Err m_err; }; + } diff --git a/src/ossia/network/http/http_client_request.hpp b/src/ossia/network/http/http_client_request.hpp index 429575473ab..2e5ca420ecf 100644 --- a/src/ossia/network/http/http_client_request.hpp +++ b/src/ossia/network/http/http_client_request.hpp @@ -1,28 +1,35 @@ #pragma once #include -#include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include namespace ossia::net { -using tcp = boost::asio::ip::tcp; -// Full HTTP client supporting all methods, custom headers, request body. -// Success callback receives (request, status_code, response_body). -// Error callback receives (request, error_message). +/// Full HTTP/1.1 client supporting all methods, custom headers and a +/// request body. Used by the QML protocols layer. +/// +/// Compared to ossia::net::http_get_request, the success callback receives +/// the HTTP status code and the body: +/// Fun(*this, status_code, body) +/// Err(*this, error_message) template class http_client_request : public std::enable_shared_from_this> { - fmt::memory_buffer m_request; - public: using std::enable_shared_from_this>::shared_from_this; @@ -31,68 +38,60 @@ class http_client_request std::string_view host, std::string_view path, const std::vector>& headers = {}, std::string_view body = {}) - : m_resolver(ctx) - , m_socket(ctx) + : m_resolver{boost::asio::make_strand(ctx)} + , m_stream{boost::asio::make_strand(ctx)} + , m_host{host} , m_fun{std::move(f)} , m_err{std::move(err)} { - m_request.reserve(256 + host.size() + path.size() + body.size()); - m_response.prepare(Fun::reserve_expect); + namespace http = boost::beast::http; - // Request line: VERB /path HTTP/1.1 - fmt::format_to(fmt::appender(m_request), "{} ", verb); - for(auto c : path) - { - if(c != ' ') - fmt::format_to(fmt::appender(m_request), "{}", c); - else - fmt::format_to(fmt::appender(m_request), "%20"); - } - fmt::format_to(fmt::appender(m_request), " HTTP/1.1\r\n"); + auto v = http::string_to_verb(std::string{verb}); + if(v == http::verb::unknown) + v = http::verb::get; + + m_req.method(v); + m_req.version(11); - // Host header (always required) - fmt::format_to(fmt::appender(m_request), "Host: {}\r\n", host); + // A response to a HEAD request never has a message body, even when it + // advertises a Content-Length. Tell the parser to stop after the headers, + // otherwise it waits for a body that never arrives and fails with + // "partial message" when the connection closes. + if(v == http::verb::head) + m_parser.skip(true); - // Track which default headers the user already provided - bool hasAccept = false; - bool hasConnection = false; - bool hasContentLength = false; - bool hasContentType = false; + m_req.target(percent_encode_spaces(path)); + m_req.set(http::field::host, std::string{host}); + + // Track which default headers the user already provided so we don't + // override them. + bool has_accept = false; + bool has_connection = false; + bool has_content_type = false; - // User-supplied headers for(const auto& [key, value] : headers) { - fmt::format_to(fmt::appender(m_request), "{}: {}\r\n", key, value); + m_req.set(key, value); if(key == "Accept") - hasAccept = true; + has_accept = true; else if(key == "Connection") - hasConnection = true; - else if(key == "Content-Length") - hasContentLength = true; + has_connection = true; else if(key == "Content-Type") - hasContentType = true; + has_content_type = true; } - // Fill in defaults for headers the user didn't set - if(!hasAccept) - fmt::format_to(fmt::appender(m_request), "Accept: */*\r\n"); - if(!hasConnection) - fmt::format_to(fmt::appender(m_request), "Connection: close\r\n"); + if(!has_accept) + m_req.set(http::field::accept, "*/*"); + if(!has_connection) + m_req.set(http::field::connection, "close"); if(!body.empty()) { - if(!hasContentLength) - fmt::format_to( - fmt::appender(m_request), "Content-Length: {}\r\n", body.size()); - if(!hasContentType) - fmt::format_to( - fmt::appender(m_request), "Content-Type: application/octet-stream\r\n"); + if(!has_content_type) + m_req.set(http::field::content_type, "application/octet-stream"); + m_req.body().assign(body.data(), body.size()); + m_req.prepare_payload(); } - - // End of headers + body - fmt::format_to(fmt::appender(m_request), "\r\n"); - if(!body.empty()) - fmt::format_to(fmt::appender(m_request), "{}", body); } void resolve(const std::string& server, const std::string& port) @@ -100,192 +99,114 @@ class http_client_request m_resolver.async_resolve( server, port, [self = this->shared_from_this()]( - const boost::system::error_code& err, - const tcp::resolver::results_type& endpoints) { - self->handle_resolve(err, endpoints); + const boost::beast::error_code& ec, + const boost::asio::ip::tcp::resolver::results_type& results) { + self->on_resolve(ec, results); }); } - void close() { m_socket.close(); } - -private: - void handle_resolve( - const boost::system::error_code& err, - const tcp::resolver::results_type& endpoints) + void close() { - if(!err) - { - boost::asio::async_connect( - m_socket, endpoints, - [self = this->shared_from_this()]( - const boost::system::error_code& err, auto&&...) { - self->handle_connect(err); - }); - } - else - { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this, err.message()); - } + boost::beast::error_code ec; + m_stream.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + m_stream.socket().close(ec); } - void handle_connect(const boost::system::error_code& err) +private: + static std::string percent_encode_spaces(std::string_view path) { - if(!err) + std::string out; + out.reserve(path.size()); + for(char c : path) { - boost::asio::const_buffer request(m_request.data(), m_request.size()); - boost::asio::async_write( - m_socket, request, - [self = this->shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_write_request(err, size); - }); - } - else - { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this, err.message()); + if(c == ' ') + out += "%20"; + else + out.push_back(c); } + return out; } - void handle_write_request(const boost::system::error_code& err, std::size_t size) + void on_resolve( + const boost::beast::error_code& ec, + const boost::asio::ip::tcp::resolver::results_type& results) { - if(!err) + if(ec) { - boost::asio::async_read_until( - m_socket, m_response, "\r\n", - [self = this->shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_status_line(err, size); - }); - } - else - { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this, err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); + m_err(*this, ec.message()); + return; } + m_stream.expires_after(std::chrono::seconds(30)); + m_stream.async_connect( + results, + [self = this->shared_from_this()]( + const boost::beast::error_code& ec, + const boost::asio::ip::tcp::resolver::results_type::endpoint_type&) { + self->on_connect(ec); + }); } - void handle_read_status_line(const boost::system::error_code& err, std::size_t size) + void on_connect(const boost::beast::error_code& ec) { - if(!err || err == boost::asio::error::eof) + if(ec) { - std::istream response_stream(&m_response); - std::string http_version; - response_stream >> http_version; - response_stream >> m_statusCode; - std::string status_message; - std::getline(response_stream, status_message); - - if(!response_stream || http_version.substr(0, 5) != "HTTP/") - { - ossia::logger().error("HTTP Error: Invalid response"); - m_err(*this, "Invalid HTTP response"); - return; - } - - // Read headers (terminated by blank line) - boost::asio::async_read_until( - m_socket, m_response, "\r\n\r\n", - [self = this->shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_headers(err, size); - }); - } - else - { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this, err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); + m_err(*this, ec.message()); + return; } + m_stream.expires_after(std::chrono::seconds(30)); + boost::beast::http::async_write( + m_stream, m_req, + [self = this->shared_from_this()]( + const boost::beast::error_code& ec, std::size_t bytes) { + self->on_write(ec, bytes); + }); } - void handle_read_headers(const boost::system::error_code& err, std::size_t size) + void on_write(const boost::beast::error_code& ec, std::size_t) { - if(!err || err == boost::asio::error::eof) + if(ec) { - std::istream response_stream(&m_response); - std::string header; - while(std::getline(response_stream, header) && header != "\r") - { - if(header.starts_with("Content-Length: ")) - { - std::string_view sz( - header.begin() + strlen("Content-Length: "), header.end()); - if(auto num = ossia::parse_relax(sz)) - m_contentLength = *num; - } - } - - if(m_contentLength == 0) - { - // Empty body (e.g. 204 No Content) - finish_read(boost::asio::error::eof, 0); - } - else if(m_contentLength > 0) - { - if(m_contentLength == (int)m_response.size()) - { - finish_read(boost::asio::error::eof, size); - } - else - { - boost::asio::async_read( - m_socket, m_response, - boost::asio::transfer_exactly(m_contentLength - m_response.size()), - [self = this->shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_content(err, size); - }); - } - } - else - { - // No Content-Length — read until EOF - boost::asio::async_read( - m_socket, m_response, boost::asio::transfer_all(), - [self = this->shared_from_this()]( - const boost::system::error_code& err, std::size_t size) { - self->handle_read_content(err, size); - }); - } - } - else - { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this, err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); + m_err(*this, ec.message()); + return; } + m_stream.expires_after(std::chrono::seconds(30)); + m_parser.body_limit(256u * 1024u * 1024u); + boost::beast::http::async_read( + m_stream, m_buffer, m_parser, + [self = this->shared_from_this()]( + const boost::beast::error_code& ec, std::size_t bytes) { + self->on_read(ec, bytes); + }); } - void handle_read_content(const boost::system::error_code& err, std::size_t size) + void on_read(const boost::beast::error_code& ec, std::size_t) { - if(!err || err == boost::asio::error::eof) - finish_read(err, size); - else + if(ec && ec != boost::beast::http::error::end_of_stream + && ec != boost::asio::error::eof) { - ossia::logger().error("HTTP Error: {}", err.message()); - m_err(*this, err.message()); + ossia::logger().error("HTTP Error: {}", ec.message()); + m_err(*this, ec.message()); + return; } - } - void finish_read(const boost::system::error_code& err, std::size_t size) - { - const auto& dat = m_response.data(); - auto begin = boost::asio::buffers_begin(dat); - auto end = boost::asio::buffers_end(dat); - auto sz = end - begin; - std::string str; - str.reserve(sz + 16); - str.assign(begin, end); - m_fun(*this, m_statusCode, str); + auto& res = m_parser.get(); + int status = (int)res.result_int(); + const auto& body = res.body(); + m_fun(*this, status, std::string_view{body.data(), body.size()}); close(); } - tcp::resolver m_resolver; - tcp::socket m_socket; - boost::asio::streambuf m_response; - int m_contentLength{-1}; - int m_statusCode{0}; + boost::asio::ip::tcp::resolver m_resolver; + boost::beast::tcp_stream m_stream; + boost::beast::flat_buffer m_buffer; + boost::beast::http::request m_req; + boost::beast::http::response_parser m_parser; + std::string m_host; Fun m_fun; Err m_err; }; + } diff --git a/src/ossia/network/oscquery/detail/get_query_parser.hpp b/src/ossia/network/oscquery/detail/get_query_parser.hpp index d7c9160ef49..b8e60ad69db 100644 --- a/src/ossia/network/oscquery/detail/get_query_parser.hpp +++ b/src/ossia/network/oscquery/detail/get_query_parser.hpp @@ -150,11 +150,7 @@ class get_query_answerer } else { - websocketpp::connection& sockets - = *proto.m_websocketServer->impl().get_con_from_hdl(hdl); - auto& socket = sockets.get_socket(); - - std::string local_client_ip = socket.local_endpoint().address().to_string(); + std::string local_client_ip = proto.m_websocketServer->get_local_ip(hdl); auto transports = [&]() -> std::vector { if constexpr(requires { proto.get_osc_port(); }) diff --git a/src/ossia/network/oscquery/detail/json_query_parser.hpp b/src/ossia/network/oscquery/detail/json_query_parser.hpp index 9bb3d362c13..49d998c9a9d 100644 --- a/src/ossia/network/oscquery/detail/json_query_parser.hpp +++ b/src/ossia/network/oscquery/detail/json_query_parser.hpp @@ -4,6 +4,9 @@ #include #include #include + +#include +#include namespace ossia::oscquery { @@ -90,10 +93,18 @@ struct json_query_answerer Protocol& proto, const typename Protocol::connection_handler& hdl, int port, int remotePort) { - // First we find for a corresponding client - auto clt = proto.find_client(hdl); - - if(!clt) + // The client's OSC sender is part of the m_clients set, which is guarded + // by m_clientsMutex (push() reads client.sender under that lock). Set it up + // while holding the same lock so we don't race the producer thread, and so + // the client cannot be removed between lookup and use (find_client alone + // releases the lock before returning a raw pointer). + std::lock_guard lock{proto.m_clientsMutex}; + + auto it = std::find_if( + proto.m_clients.begin(), proto.m_clients.end(), + [&](const auto& clt) { return *clt == hdl; }); + + if(it == proto.m_clients.end()) { // It's an http-connecting client - we can't open a streaming connection // to it @@ -103,10 +114,10 @@ struct json_query_answerer if(port != 0) { // Then we set-up the sender - clt->open_osc_sender(proto, port); + (*it)->open_osc_sender(proto, port); if(remotePort != 0) { - clt->remote_sender_port = remotePort; + (*it)->remote_sender_port = remotePort; } } diff --git a/src/ossia/network/oscquery/detail/query_parser.hpp b/src/ossia/network/oscquery/detail/query_parser.hpp index 20c75ec6955..eb581256acb 100644 --- a/src/ossia/network/oscquery/detail/query_parser.hpp +++ b/src/ossia/network/oscquery/detail/query_parser.hpp @@ -31,7 +31,9 @@ class query_parser if(idx != std::string::npos) { path = std::string_view(request.data(), idx); - queries = std::string_view(request.data() + idx + 1, request.size() - idx); + // Everything after the '?': length is size - (idx + 1), not size - idx, + // which would read one byte past the query string. + queries = std::string_view(request.data() + idx + 1, request.size() - idx - 1); } else { diff --git a/src/ossia/network/oscquery/oscquery_client.hpp b/src/ossia/network/oscquery/oscquery_client.hpp index eae1f01ad38..36101d2f7b8 100644 --- a/src/ossia/network/oscquery/oscquery_client.hpp +++ b/src/ossia/network/oscquery/oscquery_client.hpp @@ -5,7 +5,8 @@ #include #include #include -#include +#include +#include namespace osc { @@ -22,7 +23,7 @@ namespace oscquery { struct oscquery_client { - ossia::net::websocket_server::connection_handler connection; + ossia::net::ws_connection_handle connection; mutex_t listeningMutex; string_map listening TS_GUARDED_BY(listeningMutex); @@ -37,7 +38,7 @@ struct oscquery_client oscquery_client(oscquery_client&& other) noexcept = delete; oscquery_client& operator=(oscquery_client&& other) noexcept = delete; - explicit oscquery_client(ossia::net::websocket_server::connection_handler h) + explicit oscquery_client(ossia::net::ws_connection_handle h) : connection{std::move(h)} { } @@ -57,7 +58,7 @@ struct oscquery_client listening.erase(path); } - bool operator==(const ossia::net::websocket_server::connection_handler& h) const + bool operator==(const ossia::net::ws_connection_handle& h) const { return !connection.expired() && connection.lock() == h.lock(); } diff --git a/src/ossia/network/oscquery/oscquery_mirror.cpp b/src/ossia/network/oscquery/oscquery_mirror.cpp index f43fa517a0a..edb451f851b 100644 --- a/src/ossia/network/oscquery/oscquery_mirror.cpp +++ b/src/ossia/network/oscquery/oscquery_mirror.cpp @@ -16,7 +16,7 @@ #include #include #include -#include +#include #include #include @@ -564,22 +564,22 @@ void oscquery_mirror_protocol::init() this->on_OSCMessage(m, ip); }); - m_websocketClient = std::make_unique( - [this]( - connection_handler hdl, websocketpp::frame::opcode::value op, + m_websocketClient = std::make_unique( + ossia::net::ws_client_message_handler{[this]( + const ossia::net::ws_connection_handle& hdl, ossia::net::ws_opcode op, std::string& msg) { switch(op) { - case websocketpp::frame::opcode::value::TEXT: + case ossia::net::ws_opcode::text: this->on_WSMessage(hdl, msg); break; - case websocketpp::frame::opcode::value::BINARY: + case ossia::net::ws_opcode::binary: this->on_BinaryWSMessage(hdl, msg); break; default: break; } - }); + }}); m_id.identifier = (uintptr_t)m_websocketClient.get(); m_websocketClient->on_close.connect<&oscquery_mirror_protocol::on_ws_disconnected>( diff --git a/src/ossia/network/oscquery/oscquery_mirror.hpp b/src/ossia/network/oscquery/oscquery_mirror.hpp index 7785089de2f..527d88370f8 100644 --- a/src/ossia/network/oscquery/oscquery_mirror.hpp +++ b/src/ossia/network/oscquery/oscquery_mirror.hpp @@ -26,7 +26,7 @@ namespace ossia namespace net { struct parameter_data; -class websocket_client; +class websocket_client_interface; } namespace oscquery { @@ -154,7 +154,7 @@ class OSSIA_EXPORT oscquery_mirror_protocol final : public ossia::net::protocol_ std::unique_ptr> m_oscSender; std::unique_ptr m_oscServer; - std::unique_ptr m_websocketClient; + std::unique_ptr m_websocketClient; std::atomic_bool m_hasWS{}; // Listening status of the local software diff --git a/src/ossia/network/oscquery/oscquery_server.cpp b/src/ossia/network/oscquery/oscquery_server.cpp index 569531a5e0d..1810942d3ed 100644 --- a/src/ossia/network/oscquery/oscquery_server.cpp +++ b/src/ossia/network/oscquery/oscquery_server.cpp @@ -3,6 +3,7 @@ #include "oscquery_server.hpp" #include +#include #include #include #include @@ -22,7 +23,7 @@ #include #include #include -#include +#include namespace ossia { namespace oscquery @@ -55,21 +56,21 @@ oscquery_server_protocol::oscquery_server_protocol(uint16_t osc_port, uint16_t w [this](const oscpack::ReceivedMessage& m, const oscpack::IpEndpointName& ip) { this->on_OSCMessage(m, ip); })} -, m_websocketServer{std::make_unique()} +, m_websocketServer{std::make_unique(ossia::net::create_network_context())} , m_oscPort{(uint16_t)m_oscServer->port()} , m_wsPort{ws_port} { m_clients.reserve(2); m_websocketServer->set_open_handler( - [&](connection_handler hdl) { on_connectionOpen(hdl); }); + [&](ossia::net::ws_connection_handle hdl) { on_connectionOpen(hdl); }); m_websocketServer->set_close_handler( - [&](connection_handler hdl) { on_connectionClosed(hdl); }); + [&](ossia::net::ws_connection_handle hdl) { on_connectionClosed(hdl); }); m_websocketServer->set_message_handler( - [&](const connection_handler& hdl, websocketpp::frame::opcode::value op, + [&](const ossia::net::ws_connection_handle& hdl, ossia::net::ws_opcode op, const std::string& str) { switch(op) { - case websocketpp::frame::opcode::value::TEXT: { + case ossia::net::ws_opcode::text: { auto res = on_WSrequest(hdl, str); if(!res.data.empty() && res.type != ossia::net::server_reply::data_type::binary @@ -78,7 +79,7 @@ oscquery_server_protocol::oscquery_server_protocol(uint16_t osc_port, uint16_t w return res; } - case websocketpp::frame::opcode::value::BINARY: { + case ossia::net::ws_opcode::binary: { return on_BinaryWSrequest(hdl, str); } default: @@ -423,8 +424,7 @@ void oscquery_server_protocol::stop() auto it = m_clients.begin(); while(it != m_clients.end()) { - auto con = m_websocketServer->impl().get_con_from_hdl((*it)->connection); - con->close(websocketpp::close::status::going_away, "Server shutdown"); + m_websocketServer->close((*it)->connection); it = m_clients.erase(it); } } @@ -562,10 +562,7 @@ catch(...) void oscquery_server_protocol::on_connectionOpen(const connection_handler& hdl) try { - auto con = m_websocketServer->impl().get_con_from_hdl(hdl); - - boost::asio::ip::tcp::socket& sock = con->get_raw_socket(); - auto ip = sock.remote_endpoint().address().to_string(); + auto ip = m_websocketServer->get_remote_ip(hdl); if(ip.substr(0, 7) == "::ffff:") ip = ip.substr(7); @@ -575,7 +572,7 @@ try m_clients.back()->client_ip = std::move(ip); } - onClientConnected(con->get_remote_endpoint()); + onClientConnected(m_websocketServer->get_remote_endpoint(hdl)); } catch(const std::exception& e) { @@ -597,8 +594,7 @@ void oscquery_server_protocol::on_connectionClosed(const connection_handler& hdl } } - auto con = m_websocketServer->impl().get_con_from_hdl(hdl); - onClientDisconnected(con->get_remote_endpoint()); + onClientDisconnected(m_websocketServer->get_remote_endpoint(hdl)); } void oscquery_server_protocol::on_nodeCreated(const net::node_base& n) diff --git a/src/ossia/network/oscquery/oscquery_server.hpp b/src/ossia/network/oscquery/oscquery_server.hpp index 1067065dc77..6c18dd2847c 100644 --- a/src/ossia/network/oscquery/oscquery_server.hpp +++ b/src/ossia/network/oscquery/oscquery_server.hpp @@ -27,7 +27,7 @@ namespace ossia { namespace net { -class websocket_server; +class websocket_server_interface; } namespace oscquery { @@ -119,7 +119,7 @@ class OSSIA_EXPORT oscquery_server_protocol final : public ossia::net::protocol_ on_BinaryWSrequest(const connection_handler& hdl, const std::string& message); std::unique_ptr m_oscServer; - std::unique_ptr m_websocketServer; + std::unique_ptr m_websocketServer; net::zeroconf_server m_zeroconfServerWS; net::zeroconf_server m_zeroconfServerOSC; diff --git a/src/ossia/network/resolve.cpp b/src/ossia/network/resolve.cpp index 6113c7745ab..fbd48c42a48 100644 --- a/src/ossia/network/resolve.cpp +++ b/src/ossia/network/resolve.cpp @@ -8,8 +8,7 @@ namespace ossia { -static std::optional -process_resolve_results(resolved_url ret, auto results) +static bool is_local_v4(boost::asio::ip::address_v4 addr) { struct range { @@ -29,6 +28,31 @@ process_resolve_results(resolved_url ret, auto results) .max = boost::asio::ip::make_address_v4("192.168.255.255").to_uint(), }}; + auto ip = addr.to_uint(); + return (ip >= local_range[0].min && ip <= local_range[0].max) + || (ip >= local_range[1].min && ip <= local_range[1].max) + || (ip >= local_range[2].min && ip <= local_range[2].max); +} + +static bool is_local_v6(boost::asio::ip::address_v6 addr) +{ + // fc00::/7 — Unique Local Addresses (ULA), the IPv6 equivalent of RFC1918 + auto bytes = addr.to_bytes(); + return (bytes[0] & 0xfe) == 0xfc; +} + +static bool is_local_address(const boost::asio::ip::address& addr) +{ + if(addr.is_v4()) + return is_local_v4(addr.to_v4()); + if(addr.is_v6()) + return is_local_v6(addr.to_v6()); + return false; +} + +static std::optional +process_resolve_results(resolved_url ret, auto results) +{ // 1. Give priority to localhost for(auto& result : results) { @@ -36,32 +60,24 @@ process_resolve_results(resolved_url ret, auto results) { ossia::logger().info( "-> Resolved {} as a loopback ip", result.endpoint().address().to_string()); - // ret.host = "127.0.0.1"; - // Some software using shitty Java network APIs actually hardcode the - // IP they want to be talked to so we just use the found ip then, and hope for the best: ret.host = result.endpoint().address().to_string(); return ret; } } - // 2. Give priority to local addresses + // 2. Give priority to local addresses (IPv4 private + IPv6 ULA) for(auto& result : results) { - if(const auto& addr = result.endpoint().address(); addr.is_v4()) + const auto& addr = result.endpoint().address(); + if(is_local_address(addr)) { - auto ip = addr.to_v4().to_uint(); - if((ip >= local_range[0].min && ip <= local_range[0].max) - || (ip >= local_range[1].min && ip <= local_range[1].max) - || (ip >= local_range[2].min && ip <= local_range[2].max)) - { - ret.host = addr.to_string(); - ossia::logger().info("-> Resolved {} as a local network address", ret.host); - return ret; - } + ret.host = addr.to_string(); + ossia::logger().info("-> Resolved {} as a local network address", ret.host); + return ret; } } - // 3. Anything else + // 3. Anything else — prefer IPv4, then accept IPv6 for(auto& result : results) { if(const auto& addr = result.endpoint().address(); addr.is_v4()) @@ -71,6 +87,13 @@ process_resolve_results(resolved_url ret, auto results) return ret; } } + for(auto& result : results) + { + const auto& addr = result.endpoint().address(); + ret.host = addr.to_string(); + ossia::logger().info("-> Resolved {} as an internet address (v6)", ret.host); + return ret; + } ossia::logger().info("-> Failed to resolve", ret.host); return std::nullopt; @@ -111,29 +134,43 @@ resolve_sync_v4(const std::string_view host, const std::string_view port) try { resolved_url ret; - ret.protocol = Proto::v4().protocol(); - ret.family = Proto::v4().family(); ret.port = port; // Maybe we were already fed an IP: { boost::system::error_code ec; - // this calls inet_pton which is the os-level ip validation - boost::asio::ip::make_address(host, ec); + auto addr = boost::asio::ip::make_address(host, ec); if(!ec) { ret.host = host; + ret.protocol = addr.is_v6() ? Proto::v6().protocol() : Proto::v4().protocol(); + ret.family = addr.is_v6() ? Proto::v6().family() : Proto::v4().family(); return ret; } } - // Fallback to proper resolving. + // Resolve hostname — try all address families + ret.protocol = Proto::v4().protocol(); + ret.family = Proto::v4().family(); + boost::asio::io_context io_service; typename Proto::resolver resolver(io_service); auto results = resolver.resolve( - Proto::v4(), host, port, boost::asio::ip::resolver_base::numeric_service); + host, port, boost::asio::ip::resolver_base::numeric_service); - return process_resolve_results(ret, results); + auto resolved = process_resolve_results(ret, results); + if(resolved) + { + // Update protocol/family based on the resolved address + boost::system::error_code ec; + auto addr = boost::asio::ip::make_address(resolved->host, ec); + if(!ec && addr.is_v6()) + { + resolved->protocol = Proto::v6().protocol(); + resolved->family = Proto::v6().family(); + } + } + return resolved; } catch(const std::exception& e) { diff --git a/src/ossia/network/sockets/tcp_socket.hpp b/src/ossia/network/sockets/tcp_socket.hpp index 59c412d6c59..8ae36a98ed1 100644 --- a/src/ossia/network/sockets/tcp_socket.hpp +++ b/src/ossia/network/sockets/tcp_socket.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -65,10 +66,19 @@ class tcp_server tcp_server(const inbound_socket_configuration& conf, boost::asio::io_context& ctx) : m_context{ctx} - , m_acceptor{ - boost::asio::make_strand(ctx), - proto::endpoint{boost::asio::ip::make_address(conf.bind), conf.port}} + , m_acceptor{boost::asio::make_strand(ctx)} { + auto addr = boost::asio::ip::make_address(conf.bind); + proto::endpoint ep{addr, conf.port}; + m_acceptor.open(ep.protocol()); + m_acceptor.set_option(boost::asio::socket_base::reuse_address(true)); + if(addr.is_v6()) + { + boost::system::error_code ec; + m_acceptor.set_option(boost::asio::ip::v6_only(false), ec); + } + m_acceptor.bind(ep); + m_acceptor.listen(); } tcp_server( @@ -97,6 +107,12 @@ class tcp_client void connect() { boost::system::error_code ec; + m_socket.open(m_endpoint.protocol(), ec); + if(ec) + { + on_fail(); + return; + } m_socket.set_option(boost::asio::ip::tcp::no_delay{true}, ec); m_socket.set_option(boost::asio::socket_base::reuse_address{true}, ec); diff --git a/src/ossia/network/sockets/udp_socket.hpp b/src/ossia/network/sockets/udp_socket.hpp index 456522dbed9..a962c333682 100644 --- a/src/ossia/network/sockets/udp_socket.hpp +++ b/src/ossia/network/sockets/udp_socket.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -41,10 +42,15 @@ class udp_receive_socket ~udp_receive_socket() = default; - void assign(int sock) { m_socket.assign(boost::asio::ip::udp::v4(), sock); } + void assign(int sock) { m_socket.assign(m_endpoint.protocol(), sock); } void open() { - m_socket.open(boost::asio::ip::udp::v4()); + m_socket.open(m_endpoint.protocol()); + if(m_endpoint.address().is_v6()) + { + boost::system::error_code ec; + m_socket.set_option(boost::asio::ip::v6_only(false), ec); + } if(!m_multicast_group.empty()) { m_socket.set_option(boost::asio::ip::udp::socket::reuse_address(true)); @@ -152,10 +158,16 @@ class udp_send_socket void connect() { - m_socket.open(boost::asio::ip::udp::v4()); + m_socket.open(m_endpoint.protocol()); m_socket.set_option(boost::asio::ip::udp::socket::reuse_address(true)); + if(m_endpoint.address().is_v6()) + { + boost::system::error_code ec; + m_socket.set_option(boost::asio::ip::v6_only(false), ec); + } + if(m_broadcast) m_socket.set_option(boost::asio::socket_base::broadcast(true)); diff --git a/src/ossia/network/sockets/websocket.hpp b/src/ossia/network/sockets/websocket.hpp index 818a7d03d74..90b5608ef28 100644 --- a/src/ossia/network/sockets/websocket.hpp +++ b/src/ossia/network/sockets/websocket.hpp @@ -1,105 +1,6 @@ #pragma once -#include -#include -#include - -#include -/* -#include -#include -#include -#include -*/ -#include - -namespace ossia::net -{ -struct websocket_simple_client : websocket_client -{ - websocket_simple_client( - const ws_client_configuration& conf, boost::asio::io_context& ctx) - : ossia::net::websocket_client{} - , m_host{conf.url} - { - m_client->init_asio(&ctx); - } - - void connect() { } - - template - void receive(F onMessage) - { - - std::weak_ptr weak_client = m_client; - m_client->set_message_handler( - [handler = std::move(onMessage), - weak_client](connection_handler hdl, client_t::message_ptr msg) { - if(!weak_client.lock()) - return; - const auto& data = msg->get_raw_payload(); - handler((const unsigned char*)data.data(), data.size()); - }); - - websocket_client::connect(m_host); - } - - void write(const char* data, std::size_t sz) - { - send_binary_message(std::string_view{data, sz}); - } - - void close() - { - if(connected()) - { - boost::asio::post(m_client->get_io_service(), [this] { websocket_client::stop(); }); - } - } - - std::string m_host; -}; - -struct websocket_simple_server : ossia::net::websocket_server -{ - websocket_simple_server( - const ws_server_configuration& conf, ossia::net::network_context_ptr ctx) - : ossia::net::websocket_server{ctx} - , m_port{conf.port} - { - set_open_handler( - [this](connection_handler hdl) { m_listeners.push_back(hdl.lock()); }); - set_close_handler([this](connection_handler hdl) { - ossia::remove_erase(m_listeners, hdl.lock()); - }); - } - - template - void listen(F onMessage) - { - m_server->set_message_handler( - [handler - = std::move(onMessage)](connection_handler hdl, server_t::message_ptr msg) { - const auto& data = msg->get_raw_payload(); - handler((const unsigned char*)data.data(), data.size()); - }); - - websocket_server::listen(m_port); - } - - void write(const char* data, std::size_t sz) - { - std::string_view dat{data, sz}; - for(auto& listener : m_listeners) - send_binary_message(listener, dat); - } - - void close() - { - boost::asio::post(m_server->get_io_service(), [this] { stop(); }); - } - - std::vector> m_listeners; - int m_port{}; -}; - -} +// This header now just includes the beast-based simple wrappers +// for use by osc_factory.cpp (OSC-over-WebSocket). +// The old websocketpp-based websocket_simple_client/websocket_simple_server +// have been replaced by the beast variants. +#include diff --git a/src/ossia/network/sockets/websocket_client.hpp b/src/ossia/network/sockets/websocket_client.hpp deleted file mode 100644 index f1b0c7941bc..00000000000 --- a/src/ossia/network/sockets/websocket_client.hpp +++ /dev/null @@ -1,243 +0,0 @@ -#pragma once -#include - -#include -#include - -#include -#include -#include - -#include - -namespace ossia::net -{ - -//! Low-level Websocket client -class websocket_client -{ -public: - using connection_handler = websocketpp::connection_hdl; - Nano::Signal on_open; - Nano::Signal on_close; - Nano::Signal on_fail; - - websocket_client() - : m_open{false} - { - init_client(); - } - - void init_client() - { - m_client = std::make_shared(); - assert(m_client); - std::weak_ptr weak_client = m_client; - m_client->clear_access_channels(websocketpp::log::alevel::all); - m_client->clear_error_channels(websocketpp::log::elevel::all); - - m_client->set_open_handler([this, weak_client](connection_handler hdl) { - if(!weak_client.lock()) - return; - scoped_lock guard(m_lock); - m_open = true; - - on_open(); - }); - - m_client->set_close_handler([this, weak_client](connection_handler hdl) { - if(!weak_client.lock()) - return; - { - scoped_lock guard(m_lock); - m_open = false; - } - on_close(); - }); - - m_client->set_fail_handler([this, weak_client](connection_handler hdl) { - if(!weak_client.lock()) - return; - { - scoped_lock guard(m_lock); - m_open = false; - } - on_fail(); - }); - assert(m_client); - } - - //! \tparam Function that will be called when the client receives a server - //! message. - template - websocket_client(MessageHandler&& onMessage) - : websocket_client{} - { - assert(m_client); - m_client->init_asio(); - - std::weak_ptr weak_client = m_client; - m_client->set_message_handler( - [handler = std::move(onMessage), - weak_client](connection_handler hdl, client_t::message_ptr msg) { - if(!weak_client.lock()) - return; - handler(hdl, msg->get_opcode(), msg->get_raw_payload()); - }); - assert(m_client); - } - - template - websocket_client(boost::asio::io_context& ctx, MessageHandler&& onMessage) - : websocket_client{} - { - assert(m_client); - m_client->init_asio(&ctx); - m_ctx = &ctx; - - std::weak_ptr weak_client = m_client; - m_client->set_message_handler( - [handler = std::move(onMessage), - weak_client](connection_handler hdl, client_t::message_ptr msg) { - if(!weak_client.lock()) - return; - handler(hdl, msg->get_opcode(), msg->get_raw_payload()); - }); - assert(m_client); - } - - ~websocket_client() - { - if(m_open) - stop(); - } - - bool connected() const { return m_open; } - - void stop() - { - if(!m_open) - { - if(m_client) - m_client->stop(); - m_connected = false; - return; - } - - scoped_lock guard(m_lock); - m_client->close(m_hdl, websocketpp::close::status::normal, ""); - m_client->stop(); - m_open = false; - } - - auto& client() { return m_client; } - auto& handle() { return m_hdl; } - bool after_connect() { return m_connected; } - - void connect(const std::string& uri) - { - websocketpp::lib::error_code ec; - if(!m_client) - { - init_client(); - assert(m_client); - if(m_ctx) - m_client->init_asio(m_ctx); - else - m_client->init_asio(); - } - - auto con = m_client->get_connection(uri, ec); - if(ec) - { - m_client->get_alog().write( - websocketpp::log::alevel::app, "Get Connection Error: " + ec.message()); - return; - } - - m_hdl = con->get_handle(); - m_client->connect(con); - m_connected = true; - } - - void finish_connection() - { - m_connected = false; - m_client.reset(); // In order to be able to reconnect afterwards. - } - - // This function returns if the connection is stopped / fails. - void connect_and_run(const std::string& uri) - { - connect(uri); - - m_client->run(); - - finish_connection(); - } - - void send_message(const std::string& request) - { - if(!m_open || !m_client) - return; - - websocketpp::lib::error_code ec; - - m_client->send(m_hdl, request, websocketpp::frame::opcode::text, ec); - - if(ec) - { - m_client->get_alog().write( - websocketpp::log::alevel::app, "Send Error: " + ec.message()); - } - } - - void send_message(const rapidjson::StringBuffer& request) - { - if(!m_open || !m_client) - return; - - websocketpp::lib::error_code ec; - - m_client->send( - m_hdl, request.GetString(), request.GetSize(), websocketpp::frame::opcode::text, - ec); - - if(ec) - { - m_client->get_alog().write( - websocketpp::log::alevel::app, "Send Error: " + ec.message()); - } - } - - void send_binary_message(std::string_view request) - { - if(!m_open || !m_client) - return; - - websocketpp::lib::error_code ec; - - m_client->send( - m_hdl, request.data(), request.size(), websocketpp::frame::opcode::binary, ec); - - if(ec) - { - m_client->get_alog().write( - websocketpp::log::alevel::app, "Send Error: " + ec.message()); - } - } - - boost::asio::io_context& context() noexcept { return *m_ctx; } - -protected: - using client_t = websocketpp::client; - using scoped_lock = websocketpp::lib::lock_guard; - - boost::asio::io_context* m_ctx{}; - std::shared_ptr m_client; - connection_handler m_hdl; - websocketpp::lib::mutex m_lock; - std::atomic_bool m_open{false}; - std::atomic_bool m_connected{false}; -}; -} diff --git a/src/ossia/network/sockets/websocket_client_beast.cpp b/src/ossia/network/sockets/websocket_client_beast.cpp new file mode 100644 index 00000000000..aad4ebb0444 --- /dev/null +++ b/src/ossia/network/sockets/websocket_client_beast.cpp @@ -0,0 +1,295 @@ +#if !defined(__EMSCRIPTEN__) +#include + +#include + +#include + +namespace ossia::net +{ + +websocket_client_beast::websocket_client_beast(boost::asio::io_context& ctx) + : m_context{ctx} + , m_resolver{boost::asio::make_strand(ctx)} +{ +} + +websocket_client_beast::websocket_client_beast( + boost::asio::io_context& ctx, ws_client_message_handler handler) + : m_context{ctx} + , m_resolver{boost::asio::make_strand(ctx)} + , m_on_message{std::move(handler)} +{ +} + +websocket_client_beast::websocket_client_beast(ws_client_message_handler handler) + : m_owned_context{std::make_unique()} + , m_context{*m_owned_context} + , m_resolver{boost::asio::make_strand(*m_owned_context)} + , m_on_message{std::move(handler)} +{ +} + +websocket_client_beast::~websocket_client_beast() +{ + if(m_open) + stop(); +} + +void websocket_client_beast::connect(const std::string& uri) +{ + auto parsed = parse_websocket_uri(uri); + + // TLS is not implemented in the beast backend yet. Rather than silently + // connecting in plaintext to a wss:// endpoint (a security downgrade), fail + // loudly so the caller knows the secure expectation could not be met. + if(parsed.secure) + { + ossia::logger().error( + "wss:// / https:// requested but TLS is not supported by this " + "WebSocket backend: {}", + uri); + m_connected = false; + on_fail(); + return; + } + + const auto& host = parsed.host; + const auto& port = parsed.port; + const auto& path = parsed.path; + + m_host = host; + m_ws = std::make_unique< + boost::beast::websocket::stream>( + boost::asio::make_strand(m_context)); + + m_ws->set_option( + boost::beast::websocket::stream_base::timeout::suggested( + boost::beast::role_type::client)); + + m_ws->set_option( + boost::beast::websocket::stream_base::decorator( + [](boost::beast::websocket::request_type& req) { + req.set(boost::beast::http::field::user_agent, "ossia"); + })); + + m_connected = true; + do_resolve(host, port, path); +} + +void websocket_client_beast::connect_and_run(const std::string& uri) +{ + connect(uri); + m_context.run(); + m_context.restart(); + m_connected = false; +} + +void websocket_client_beast::stop() +{ + m_connected = false; + m_open = false; + + // Forcefully close the underlying TCP socket. + // A synchronous WebSocket close handshake would deadlock if the + // peer's io_context is not running. + { + std::lock_guard lock{m_mutex}; + if(m_ws) + { + boost::beast::error_code ec; + boost::beast::get_lowest_layer(*m_ws).socket().shutdown( + boost::asio::ip::tcp::socket::shutdown_both, ec); + boost::beast::get_lowest_layer(*m_ws).socket().close(ec); + } + } + + // If we own the io_context, stop it so that run() returns in connect_and_run(). + if(m_owned_context) + m_owned_context->stop(); +} + +bool websocket_client_beast::connected() const +{ + return m_open; +} + +void websocket_client_beast::send_message(const std::string& request) +{ + enqueue_write(true, request); +} + +void websocket_client_beast::send_message(const rapidjson::StringBuffer& request) +{ + enqueue_write(true, std::string(request.GetString(), request.GetSize())); +} + +void websocket_client_beast::send_binary_message(std::string_view request) +{ + enqueue_write(false, std::string(request)); +} + +void websocket_client_beast::enqueue_write(bool text, std::string data) +{ + if(!m_open || !m_ws) + return; + + // A beast websocket::stream is not thread-safe. send_*() is called from the + // producer thread, so marshal the write onto the stream strand and serialize + // through a queue (one async_write in flight at a time, payload copied here + // so it outlives the operation). This never runs concurrently with the read. + boost::asio::dispatch( + m_ws->get_executor(), + [this, text, data = std::move(data)]() mutable { + if(!m_ws) + return; + const bool idle = m_write_queue.empty(); + m_write_queue.emplace_back(text, std::move(data)); + if(idle) + do_write(); + }); +} + +void websocket_client_beast::do_write() +{ + m_ws->text(m_write_queue.front().first); + m_ws->async_write( + boost::asio::buffer(m_write_queue.front().second), + [this](boost::beast::error_code ec, std::size_t bytes) { + on_write(ec, bytes); + }); +} + +void websocket_client_beast::on_write(boost::beast::error_code ec, std::size_t) +{ + if(ec) + { + ossia::logger().error("WS send error: {}", ec.message()); + m_write_queue.clear(); + return; + } + + m_write_queue.pop_front(); + if(!m_write_queue.empty()) + do_write(); +} + +void websocket_client_beast::do_resolve( + const std::string& host, const std::string& port, const std::string& path) +{ + m_resolver.async_resolve( + host, port, + [this, path]( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type results) { + on_resolve(ec, std::move(results), path); + }); +} + +void websocket_client_beast::on_resolve( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type results, std::string path) +{ + if(ec) + { + m_connected = false; + on_fail(); + return; + } + + boost::beast::get_lowest_layer(*m_ws).async_connect( + results, + [this, path = std::move(path)]( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type::endpoint_type ep) { + on_connect(ec, ep, std::move(path)); + }); +} + +void websocket_client_beast::on_connect( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type::endpoint_type, + std::string path) +{ + if(ec) + { + m_connected = false; + on_fail(); + return; + } + + boost::beast::get_lowest_layer(*m_ws).expires_never(); + boost::asio::ip::tcp::no_delay opt(true); + try + { + boost::beast::get_lowest_layer(*m_ws).socket().set_option(opt); + } + catch(...) + { + } + + // Use host:port for the Host header. remote_endpoint() can throw if the + // peer disconnected meanwhile; this runs inside an io_context handler, where + // an exception would propagate out of run() and stop all networking, so use + // the non-throwing overload. + auto host = m_host; + { + boost::beast::error_code ep_ec; + auto ep = boost::beast::get_lowest_layer(*m_ws).socket().remote_endpoint(ep_ec); + if(!ep_ec) + host += ":" + std::to_string(ep.port()); + } + + m_ws->async_handshake( + host, path, + [this](boost::beast::error_code ec) { on_handshake(ec); }); +} + +void websocket_client_beast::on_handshake(boost::beast::error_code ec) +{ + if(ec) + { + m_connected = false; + on_fail(); + return; + } + + m_open = true; + on_open(); + + // Start reading + do_read(); +} + +void websocket_client_beast::do_read() +{ + m_buffer.clear(); + m_ws->async_read( + m_buffer, + [this](boost::beast::error_code ec, std::size_t bytes) { + on_read(ec, bytes); + }); +} + +void websocket_client_beast::on_read(boost::beast::error_code ec, std::size_t) +{ + if(ec) + { + m_open = false; + on_close(); + return; + } + + if(m_on_message) + { + auto opcode = m_ws->got_text() ? ws_opcode::text : ws_opcode::binary; + auto data = boost::beast::buffers_to_string(m_buffer.data()); + ws_connection_handle hdl; // not used for client + m_on_message(hdl, opcode, data); + } + + do_read(); +} + +} +#endif diff --git a/src/ossia/network/sockets/websocket_client_beast.hpp b/src/ossia/network/sockets/websocket_client_beast.hpp new file mode 100644 index 00000000000..fb3f6f6fc70 --- /dev/null +++ b/src/ossia/network/sockets/websocket_client_beast.hpp @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ossia::net +{ + +/// Beast-based WebSocket client implementing websocket_client_interface. +class OSSIA_EXPORT websocket_client_beast final : public websocket_client_interface +{ +public: + websocket_client_beast(boost::asio::io_context& ctx); + + /// Construct with a message handler callback. + websocket_client_beast( + boost::asio::io_context& ctx, ws_client_message_handler handler); + + /// Construct with own io_context and a message handler callback. + /// The io_context is run internally by connect_and_run(). + explicit websocket_client_beast(ws_client_message_handler handler); + + ~websocket_client_beast() override; + + void connect(const std::string& uri) override; + void connect_and_run(const std::string& uri) override; + void stop() override; + bool connected() const override; + + void send_message(const std::string& request) override; + void send_message(const rapidjson::StringBuffer& request) override; + void send_binary_message(std::string_view request) override; + +private: + void do_resolve(const std::string& host, const std::string& port, const std::string& path); + void on_resolve( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type results, + std::string path); + void on_connect( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type::endpoint_type ep, + std::string path); + void on_handshake(boost::beast::error_code ec); + void do_read(); + void on_read(boost::beast::error_code ec, std::size_t bytes); + + // Marshal a send onto the stream strand and serialize writes. + void enqueue_write(bool text, std::string data); + void do_write(); + void on_write(boost::beast::error_code ec, std::size_t bytes); + + std::unique_ptr m_owned_context; + boost::asio::io_context& m_context; + boost::asio::ip::tcp::resolver m_resolver; + std::unique_ptr> m_ws; + boost::beast::flat_buffer m_buffer; + + ws_client_message_handler m_on_message; + std::string m_host; + std::mutex m_mutex; + std::atomic_bool m_open{false}; + std::atomic_bool m_connected{false}; + + // Outbound queue, only touched on the stream strand. {is_text, payload}. + std::deque> m_write_queue; +}; + +} diff --git a/src/ossia/network/sockets/websocket_client_emscripten.cpp b/src/ossia/network/sockets/websocket_client_emscripten.cpp new file mode 100644 index 00000000000..e5581fabe67 --- /dev/null +++ b/src/ossia/network/sockets/websocket_client_emscripten.cpp @@ -0,0 +1,248 @@ +#if defined(__EMSCRIPTEN__) +#include + +#include +#include + +#include + +#include + +#include + +namespace ossia::net +{ + +namespace +{ +using state_t = websocket_client_emscripten::state; +using state_ptr = std::shared_ptr; + +// userData is a heap-allocated `shared_ptr*` so callbacks can take a +// strong reference to state for the duration of any posted continuation. +state_ptr state_from(void* userData) +{ + if(!userData) + return {}; + auto* sp = static_cast(userData); + return *sp; +} + +EM_BOOL on_open_cb(int, const EmscriptenWebSocketOpenEvent*, void* userData) +{ + auto sp = state_from(userData); + if(!sp || !sp->active.load()) + return EM_TRUE; + sp->open.store(true); + boost::asio::post(sp->context, [sp] { + if(sp->active.load() && sp->on_open) + (*sp->on_open)(); + }); + return EM_TRUE; +} + +EM_BOOL on_close_cb(int, const EmscriptenWebSocketCloseEvent*, void* userData) +{ + auto sp = state_from(userData); + if(!sp || !sp->active.load()) + return EM_TRUE; + sp->open.store(false); + boost::asio::post(sp->context, [sp] { + if(sp->active.load() && sp->on_close) + (*sp->on_close)(); + }); + return EM_TRUE; +} + +EM_BOOL on_error_cb(int, const EmscriptenWebSocketErrorEvent*, void* userData) +{ + auto sp = state_from(userData); + if(!sp || !sp->active.load()) + return EM_TRUE; + sp->open.store(false); + boost::asio::post(sp->context, [sp] { + if(sp->active.load() && sp->on_fail) + (*sp->on_fail)(); + }); + return EM_TRUE; +} + +EM_BOOL on_message_cb( + int, const EmscriptenWebSocketMessageEvent* ev, void* userData) +{ + auto sp = state_from(userData); + if(!sp || !sp->active.load() || !ev || !ev->data || ev->numBytes == 0) + return EM_TRUE; + + // The browser reuses ev->data immediately after the callback returns: + // copy into an owned buffer before posting. + auto opcode = ev->isText ? ws_opcode::text : ws_opcode::binary; + std::string payload( + reinterpret_cast(ev->data), ev->numBytes); + + boost::asio::post( + sp->context, + [sp, opcode, data = std::move(payload)]() mutable { + if(!sp->active.load()) + return; + if(sp->on_message) + { + ws_connection_handle hdl; // unused for client + sp->on_message(hdl, opcode, data); + } + }); + return EM_TRUE; +} +} + +websocket_client_emscripten::websocket_client_emscripten( + boost::asio::io_context& ctx) + : m_state{std::make_shared(ctx, ws_client_message_handler{})} +{ + m_state->on_open = &this->on_open; + m_state->on_close = &this->on_close; + m_state->on_fail = &this->on_fail; +} + +websocket_client_emscripten::websocket_client_emscripten( + boost::asio::io_context& ctx, ws_client_message_handler handler) + : m_state{std::make_shared(ctx, std::move(handler))} +{ + m_state->on_open = &this->on_open; + m_state->on_close = &this->on_close; + m_state->on_fail = &this->on_fail; +} + +websocket_client_emscripten::~websocket_client_emscripten() +{ + stop(); +} + +void websocket_client_emscripten::connect(const std::string& uri) +{ + if(!emscripten_websocket_is_supported()) + { + ossia::logger().error( + "websocket_client_emscripten: WebSockets not supported by browser"); + on_fail(); + return; + } + + EmscriptenWebSocketCreateAttributes attrs; + emscripten_websocket_init_create_attributes(&attrs); + attrs.url = uri.c_str(); + attrs.protocols = nullptr; + attrs.createOnMainThread = EM_TRUE; + + EMSCRIPTEN_WEBSOCKET_T sock = emscripten_websocket_new(&attrs); + if(sock <= 0) + { + ossia::logger().error( + "websocket_client_emscripten: failed to create socket ({})", (int)sock); + on_fail(); + return; + } + + { + std::lock_guard lock{m_state->mutex}; + m_state->socket = sock; + } + + // Heap-allocated shared_ptr kept alive for the connection's + // lifetime so that browser callbacks can take a strong reference even + // if our public object is destroyed before all in-flight callbacks + // complete. Intentionally never freed (16-byte leak per connection): + // there is no way to synchronously drain pending browser events. + auto* userdata = new state_ptr(m_state); + emscripten_websocket_set_onopen_callback(sock, userdata, on_open_cb); + emscripten_websocket_set_onclose_callback(sock, userdata, on_close_cb); + emscripten_websocket_set_onerror_callback(sock, userdata, on_error_cb); + emscripten_websocket_set_onmessage_callback(sock, userdata, on_message_cb); +} + +void websocket_client_emscripten::connect_and_run(const std::string& uri) +{ + // The browser drives the event loop; there is nothing meaningful to "run" + // and blocking the calling thread would freeze the page. Just connect. + connect(uri); +} + +void websocket_client_emscripten::stop() +{ + std::shared_ptr st = m_state; + if(!st) + return; + + st->active.store(false); + st->open.store(false); + + int sock = 0; + { + std::lock_guard lock{st->mutex}; + sock = st->socket; + st->socket = 0; + } + + if(sock > 0) + { + emscripten_websocket_close(sock, 1000, "going away"); + emscripten_websocket_delete(sock); + } +} + +bool websocket_client_emscripten::connected() const +{ + return m_state && m_state->open.load(); +} + +void websocket_client_emscripten::send_message(const std::string& request) +{ + if(!m_state || !m_state->open.load()) + return; + std::lock_guard lock{m_state->mutex}; + if(m_state->socket > 0) + { + auto rc = emscripten_websocket_send_utf8_text( + m_state->socket, request.c_str()); + if(rc < 0) + ossia::logger().error( + "websocket_client_emscripten: send_utf8_text failed ({})", (int)rc); + } +} + +void websocket_client_emscripten::send_message( + const rapidjson::StringBuffer& request) +{ + if(!m_state || !m_state->open.load()) + return; + std::lock_guard lock{m_state->mutex}; + if(m_state->socket > 0) + { + // rapidjson::StringBuffer is null-terminated. + auto rc = emscripten_websocket_send_utf8_text( + m_state->socket, request.GetString()); + if(rc < 0) + ossia::logger().error( + "websocket_client_emscripten: send_utf8_text failed ({})", (int)rc); + } +} + +void websocket_client_emscripten::send_binary_message(std::string_view request) +{ + if(!m_state || !m_state->open.load()) + return; + std::lock_guard lock{m_state->mutex}; + if(m_state->socket > 0) + { + auto rc = emscripten_websocket_send_binary( + m_state->socket, const_cast(request.data()), + static_cast(request.size())); + if(rc < 0) + ossia::logger().error( + "websocket_client_emscripten: send_binary failed ({})", (int)rc); + } +} + +} + +#endif diff --git a/src/ossia/network/sockets/websocket_client_emscripten.hpp b/src/ossia/network/sockets/websocket_client_emscripten.hpp new file mode 100644 index 00000000000..61fac3dd4a3 --- /dev/null +++ b/src/ossia/network/sockets/websocket_client_emscripten.hpp @@ -0,0 +1,75 @@ +#pragma once +#if defined(__EMSCRIPTEN__) + +#include +#include + +#include + +#include +#include +#include +#include + +namespace ossia::net +{ + +/// WebSocket client implementation for Emscripten / WebAssembly builds. +/// +/// Wraps the browser's native WebSocket API exposed through +/// ``. The browser handles `wss://` automatically +/// using its own trust store, so no TLS configuration is needed (or in +/// fact, possible) on this backend. +/// +/// All event callbacks are marshalled back into the supplied io_context +/// via boost::asio::post so that user code observes them on the libossia +/// network thread, mirroring the Beast implementation. +class OSSIA_EXPORT websocket_client_emscripten final + : public websocket_client_interface +{ +public: + explicit websocket_client_emscripten(boost::asio::io_context& ctx); + websocket_client_emscripten( + boost::asio::io_context& ctx, ws_client_message_handler handler); + + ~websocket_client_emscripten() override; + + void connect(const std::string& uri) override; + void connect_and_run(const std::string& uri) override; + void stop() override; + bool connected() const override; + + void send_message(const std::string& request) override; + void send_message(const rapidjson::StringBuffer& request) override; + void send_binary_message(std::string_view request) override; + +private: + /// Per-instance shared state. Held by a shared_ptr so that the C-style + /// callbacks (which receive a raw `void*` userData) can safely outlive + /// the public object even if the destructor races with an in-flight + /// browser event. + struct state + { + state(boost::asio::io_context& c, ws_client_message_handler h) + : context{c} + , on_message{std::move(h)} + { + } + + boost::asio::io_context& context; + ws_client_message_handler on_message; + Nano::Signal* on_open{}; + Nano::Signal* on_close{}; + Nano::Signal* on_fail{}; + std::atomic_bool active{true}; + std::atomic_bool open{false}; + int socket{0}; // EMSCRIPTEN_WEBSOCKET_T + std::mutex mutex; + }; + + std::shared_ptr m_state; +}; + +} + +#endif diff --git a/src/ossia/network/sockets/websocket_client_interface.cpp b/src/ossia/network/sockets/websocket_client_interface.cpp new file mode 100644 index 00000000000..71e90f99606 --- /dev/null +++ b/src/ossia/network/sockets/websocket_client_interface.cpp @@ -0,0 +1,6 @@ +#include + +namespace ossia::net +{ +websocket_client_interface::~websocket_client_interface() = default; +} diff --git a/src/ossia/network/sockets/websocket_client_interface.hpp b/src/ossia/network/sockets/websocket_client_interface.hpp new file mode 100644 index 00000000000..89dd2b6369f --- /dev/null +++ b/src/ossia/network/sockets/websocket_client_interface.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include + +namespace rapidjson +{ +class CrtAllocator; +template +class MemoryPoolAllocator; +template +class GenericStringBuffer; +template +struct UTF8; +using StringBuffer = GenericStringBuffer, CrtAllocator>; +} + +namespace ossia::net +{ + +/// Callback: (handle, opcode, payload) +using ws_client_message_handler = std::function< + void(const ws_connection_handle&, ws_opcode, std::string&)>; + +/// Abstract interface for a WebSocket client. +/// +/// Both plain beast and Socket.IO client implementations +/// derive from this interface. +class OSSIA_EXPORT websocket_client_interface +{ +public: + virtual ~websocket_client_interface(); + + /// Start an async connection to the given URI. + virtual void connect(const std::string& uri) = 0; + + /// Connect and block until the connection closes or fails. + virtual void connect_and_run(const std::string& uri) = 0; + + virtual void stop() = 0; + virtual bool connected() const = 0; + + virtual void send_message(const std::string& request) = 0; + virtual void send_message(const rapidjson::StringBuffer& request) = 0; + virtual void send_binary_message(std::string_view request) = 0; + + Nano::Signal on_open; + Nano::Signal on_close; + Nano::Signal on_fail; +}; + +} diff --git a/src/ossia/network/sockets/websocket_common.hpp b/src/ossia/network/sockets/websocket_common.hpp new file mode 100644 index 00000000000..75c06748162 --- /dev/null +++ b/src/ossia/network/sockets/websocket_common.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +namespace ossia::net +{ + +/// WebSocket frame opcode +enum class ws_opcode +{ + text, + binary +}; + +/// Type-erased connection handle for WebSocket connections. +/// Both websocketpp and beast use weak_ptr for this purpose. +using ws_connection_handle = std::weak_ptr; + +/// Parsed pieces of a ws://, wss://, http:// or https:// URI. +struct ws_parsed_uri +{ + std::string host; + std::string port; + std::string path; + bool secure{false}; // true for wss:// or https:// +}; + +/// Parses a URI of the form scheme://host[:port][/path]. The scheme can be +/// ws://, wss://, http:// or https://; if missing it is treated as ws://. +/// When no port is specified, the default depends on the scheme: 443 for +/// secure, 80 otherwise. +inline ws_parsed_uri parse_websocket_uri(std::string_view uri) +{ + ws_parsed_uri out; + std::string_view sv = uri; + + if(sv.starts_with("wss://")) + { + sv.remove_prefix(6); + out.secure = true; + } + else if(sv.starts_with("https://")) + { + sv.remove_prefix(8); + out.secure = true; + } + else if(sv.starts_with("ws://")) + { + sv.remove_prefix(5); + } + else if(sv.starts_with("http://")) + { + sv.remove_prefix(7); + } + + // Find path + auto path_pos = sv.find('/'); + if(path_pos != std::string_view::npos) + { + out.path.assign(sv.substr(path_pos)); + sv = sv.substr(0, path_pos); + } + else + { + out.path = "/"; + } + + // Find port. IPv6 literals are surrounded by [], so be careful with the + // last ':' search to skip the brackets. + if(!sv.empty() && sv.front() == '[') + { + auto close = sv.find(']'); + if(close != std::string_view::npos) + { + out.host.assign(sv.substr(1, close - 1)); + sv = sv.substr(close + 1); + if(!sv.empty() && sv.front() == ':') + out.port.assign(sv.substr(1)); + } + else + { + out.host.assign(sv); + } + } + else + { + auto port_pos = sv.find(':'); + if(port_pos != std::string_view::npos) + { + out.host.assign(sv.substr(0, port_pos)); + out.port.assign(sv.substr(port_pos + 1)); + } + else + { + out.host.assign(sv); + } + } + + if(out.port.empty()) + out.port = out.secure ? "443" : "80"; + + return out; +} + +} diff --git a/src/ossia/network/sockets/websocket_factory.cpp b/src/ossia/network/sockets/websocket_factory.cpp new file mode 100644 index 00000000000..5a114d4e727 --- /dev/null +++ b/src/ossia/network/sockets/websocket_factory.cpp @@ -0,0 +1,30 @@ +#include + +#if defined(__EMSCRIPTEN__) +#include +#else +#include +#endif + +namespace ossia::net +{ + +std::unique_ptr make_websocket_client( + boost::asio::io_context& ctx, ws_client_message_handler handler, + websocket_backend backend) +{ +#if defined(__EMSCRIPTEN__) + // Only the Emscripten backend is available in WebAssembly builds; the + // browser handles `wss://` automatically. + (void)backend; + return std::make_unique(ctx, std::move(handler)); +#else + // The Socket.IO client lives in a different translation unit (and pulls + // in boost::json), so we don't construct it from here. Callers that want + // it should keep instantiating it directly. Anything else maps to Beast. + (void)backend; + return std::make_unique(ctx, std::move(handler)); +#endif +} + +} diff --git a/src/ossia/network/sockets/websocket_factory.hpp b/src/ossia/network/sockets/websocket_factory.hpp new file mode 100644 index 00000000000..c55b1993860 --- /dev/null +++ b/src/ossia/network/sockets/websocket_factory.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include +#include + +namespace boost::asio +{ +class io_context; +} + +namespace ossia::net +{ + +/// Backend selection for the websocket client factory. +/// +/// auto: pick the best implementation for the current build: +/// - Emscripten target: native browser WebSocket via emscripten_websocket_* +/// - Otherwise: Boost.Beast (plaintext TCP only) +/// TLS (wss://) is not yet implemented in the Beast backend: a wss:// URI is +/// rejected with a failure rather than being silently downgraded to plaintext. +/// Other values force a specific implementation when available; if the +/// requested backend is not available, the factory falls back to `auto`. +enum class websocket_backend +{ + automatic, + beast, + emscripten, + socketio, +}; + +/// Construct a websocket client implementing websocket_client_interface. +/// +/// The implementation is chosen based on the active build (Emscripten vs +/// native), the requested backend, and the URL scheme. The returned object +/// can be `connect()`-ed to a `ws://` or `http://` URI. Secure schemes +/// (`wss://`, `https://`) are not yet supported by the Beast backend and the +/// connection will fail rather than fall back to plaintext. +OSSIA_EXPORT +std::unique_ptr make_websocket_client( + boost::asio::io_context& ctx, ws_client_message_handler handler, + websocket_backend backend = websocket_backend::automatic); + +} diff --git a/src/ossia/network/sockets/websocket_header_client.hpp b/src/ossia/network/sockets/websocket_header_client.hpp new file mode 100644 index 00000000000..1318068cf73 --- /dev/null +++ b/src/ossia/network/sockets/websocket_header_client.hpp @@ -0,0 +1,161 @@ +#pragma once + +// A minimal, fully header-only WebSocket client. +// +// Unlike websocket_client_beast (whose implementation lives in a .cpp and thus +// requires linking libossia), this client is entirely inline so it can be used +// by tiny standalone executables that consume ossia headers only +// (e.g. score's vst/vst3/clap puppets, which link `$` and +// must not depend on libossia.so). +// +// It supports exactly what those tools need: connect to a ws:// endpoint, fire +// on_open/on_fail/on_close, send text/binary frames and close. It is meant to +// be driven by a single-threaded io_context (the caller runs ctx.run()), so +// sends are issued synchronously from that same thread. + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace ossia::net +{ + +class websocket_simple_header_client +{ +public: + explicit websocket_simple_header_client(boost::asio::io_context& ctx) + : m_resolver{boost::asio::make_strand(ctx)} + , m_ws{boost::asio::make_strand(ctx)} + { + } + + void connect(std::string_view uri) + { + auto parsed = parse_websocket_uri(uri); + m_host = parsed.host; + m_path = parsed.path; + m_resolver.async_resolve( + parsed.host, parsed.port, + [this]( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type results) { + on_resolve(ec, std::move(results)); + }); + } + + void send_message(std::string_view msg) + { + if(!m_open) + return; + boost::beast::error_code ec; + m_ws.text(true); + m_ws.write(boost::asio::buffer(msg.data(), msg.size()), ec); + } + + void send_binary_message(std::string_view msg) + { + if(!m_open) + return; + boost::beast::error_code ec; + m_ws.binary(true); + m_ws.write(boost::asio::buffer(msg.data(), msg.size()), ec); + } + + void close() + { + m_open = false; + boost::beast::error_code ec; + auto& tcp = boost::beast::get_lowest_layer(m_ws); + tcp.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + tcp.socket().close(ec); + } + + bool connected() const { return m_open; } + + Nano::Signal on_open; + Nano::Signal on_close; + Nano::Signal on_fail; + +private: + void on_resolve( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type results) + { + if(ec) + { + on_fail(); + return; + } + boost::beast::get_lowest_layer(m_ws).async_connect( + results, + [this]( + boost::beast::error_code ec, + boost::asio::ip::tcp::resolver::results_type::endpoint_type) { + on_connect(ec); + }); + } + + void on_connect(boost::beast::error_code ec) + { + if(ec) + { + on_fail(); + return; + } + boost::beast::get_lowest_layer(m_ws).expires_never(); + m_ws.async_handshake( + m_host, m_path, [this](boost::beast::error_code ec) { on_handshake(ec); }); + } + + void on_handshake(boost::beast::error_code ec) + { + if(ec) + { + on_fail(); + return; + } + m_open = true; + on_open(); + do_read(); + } + + void do_read() + { + // We don't process incoming frames, but keep a read in flight so the + // io_context stays alive and connection errors surface as on_close. + m_ws.async_read( + m_buffer, [this](boost::beast::error_code ec, std::size_t) { + if(ec) + { + if(m_open) + { + m_open = false; + on_close(); + } + return; + } + m_buffer.clear(); + do_read(); + }); + } + + boost::asio::ip::tcp::resolver m_resolver; + boost::beast::websocket::stream m_ws; + boost::beast::flat_buffer m_buffer; + std::string m_host; + std::string m_path; + bool m_open{false}; +}; + +} diff --git a/src/ossia/network/sockets/websocket_server.hpp b/src/ossia/network/sockets/websocket_server.hpp deleted file mode 100644 index d95c5ea3818..00000000000 --- a/src/ossia/network/sockets/websocket_server.hpp +++ /dev/null @@ -1,303 +0,0 @@ -#pragma once -#include - -#include -#include -#include -#include -#include - -#include -#include -#include -#if defined(OSSIA_BENCHMARK) -#include -#endif -namespace ossia::net -{ - -//! Low-level websocket & http server for oscquery -class websocket_server -{ -public: - using config = websocketpp::config::asio; - using transport_type = websocketpp::config::asio::transport_type; - using server_t = websocketpp::server; - using type = server_t; - using connection_handler = websocketpp::connection_hdl; - - typedef typename config::concurrency_type concurrency_type; - typedef websocketpp::connection connection_type; - typedef typename connection_type::ptr connection_ptr; - typedef typename transport_type::transport_con_type transport_con_type; - typedef typename transport_con_type::ptr transport_con_ptr; - typedef websocketpp::endpoint endpoint_type; - - websocket_server() - : m_server{std::make_shared()} - , m_owns_context{true} - { - m_server->init_asio(); - m_server->set_reuse_addr(true); - m_server->clear_access_channels(websocketpp::log::alevel::all); - m_server->set_socket_init_handler(init_handler); - } - - websocket_server(ossia::net::network_context_ptr ctx) - : m_server{std::make_shared()} - , m_context{ctx} - , m_owns_context{false} - { - m_server->init_asio(&ctx->context); - m_server->set_reuse_addr(true); - m_server->clear_access_channels(websocketpp::log::alevel::all); - m_server->set_socket_init_handler(init_handler); - } - - static void init_handler(websocketpp::connection_hdl, boost::asio::ip::tcp::socket& s) - { - boost::asio::ip::tcp::no_delay option(true); - try - { - s.set_option(option); - } - catch(...) - { - ossia::logger().trace("Could not set TCP nodelay option"); - } - } - - template - void set_open_handler(Handler h) - { - m_server->set_open_handler(h); - } - - template - void set_close_handler(Handler h) - { - m_server->set_close_handler(h); - } - - template - void set_message_handler(Handler h) - { - m_server->set_message_handler( - [this, h](connection_handler hdl, server_t::message_ptr msg) { -#if defined OSSIA_BENCHMARK - auto t1 = std::chrono::high_resolution_clock::now(); -#endif - try - { - auto res = h(hdl, msg->get_opcode(), msg->get_raw_payload()); - if(res.data.size() > 0) - { - send_message(hdl, res); - } - } - catch(const ossia::node_not_found_error& e) - { - auto con = m_server->get_con_from_hdl(hdl); - ossia::logger().error( - "Node not found: {} ==> {}", con->get_uri()->get_resource(), e.what()); - } - catch(const ossia::bad_request_error& e) - { - auto con = m_server->get_con_from_hdl(hdl); - ossia::logger().error( - "Error in request: {} ==> {}", con->get_uri()->get_resource(), e.what()); - } - catch(const std::exception& e) - { - auto con = m_server->get_con_from_hdl(hdl); - ossia::logger().error("Error in request: {}", e.what()); - } - catch(...) - { - auto con = m_server->get_con_from_hdl(hdl); - ossia::logger().error("Error in request"); - } - -#if defined OSSIA_BENCHMARK - auto t2 = std::chrono::high_resolution_clock::now(); - ossia::logger().info( - "Time taken: {}", - std::chrono::duration_cast(t2 - t1).count()); -#endif - }); - - m_server->set_http_handler([this, h](connection_handler hdl) { - auto con = m_server->get_con_from_hdl(hdl); - - // enable cross origin requests from anywhere - con->append_header("Access-Control-Allow-Origin", "*"); - - try - { - ossia::net::server_reply str - = h(hdl, websocketpp::frame::opcode::TEXT, con->get_uri()->get_resource()); - - switch(str.type) - { - case server_reply::data_type::json: { - con->replace_header("Content-Type", "application/json; charset=utf-8"); - str.data += "\0"; - break; - } - case server_reply::data_type::html: { - con->replace_header("Content-Type", "text/html; charset=utf-8"); - break; - } - default: - break; - } - con->replace_header("Connection", "close"); - con->set_body(std::move(str.data)); - con->set_status(websocketpp::http::status_code::ok); - return; - } - catch(const ossia::node_not_found_error& e) - { - con->set_status(websocketpp::http::status_code::not_found); - } - catch(const ossia::bad_request_error& e) - { - ossia::logger().error( - "Error in request: {} ==> {}", con->get_uri()->get_resource(), e.what()); - con->set_status(websocketpp::http::status_code::bad_request); - } - catch(const std::exception& e) - { - ossia::logger().error("Error in request: {}", e.what()); - } - catch(...) - { - ossia::logger().error("Error in request"); - } - con->set_status(websocketpp::http::status_code::internal_server_error); - }); - } - - void listen(uint16_t port = 9002) - { - m_server->listen(boost::asio::ip::tcp::v4(), port); - - boost::system::error_code ec; - start_accept(ec); - } - - void start_accept(boost::system::error_code& ec) - { - ec = boost::system::error_code(); - auto con = m_server->get_connection(ec); - - if(!con) - { - ///ec = boost::asio::error::make_error_code(boost::asio::error::con_creation_failed); - return; - } - - namespace lib = websocketpp::lib; - m_server->transport_type::async_accept( - lib::static_pointer_cast(con), - [server = m_server, con](lib::error_code ec) { - if(server) - { - if(ec) - ec = websocketpp::error::http_connection_ended; - server->handle_accept_legacy(con, ec); - } - }, ec); - - if(ec && con) - { - // If the connection was constructed but the accept failed, - // terminate the connection to prevent memory leaks - con->terminate(lib::error_code()); - } - } - - void run() { m_server->run(); } - - void stop() - { - // this change was undone because of OSSIA/libossia#416 : - - // // (temporarily?) changed to stop_listening() - // // "Straight up stop forcibly stops a bunch of things - // // in a way that bypasses most, if not all, of the cleanup routines" - - try - { - boost::system::error_code ec; - if(m_server->is_listening()) - m_server->stop_listening(ec); - } - catch(...) - { - } - - if(m_owns_context) - { - m_server->stop(); - } - else - { - boost::asio::post( - m_context->context, [s = m_server]() mutable { new decltype(s){s}; }); - } - } - - void close(connection_handler hdl) - { - auto con = m_server->get_con_from_hdl(hdl); - con->close(websocketpp::close::status::going_away, "Server shutdown"); - } - - void send_message(connection_handler hdl, const std::string& message) - { - auto con = m_server->get_con_from_hdl(hdl); - con->send(message); - } - - void send_message(connection_handler hdl, const ossia::net::server_reply& message) - { - auto con = m_server->get_con_from_hdl(hdl); - switch(message.type) - { - case server_reply::data_type::json: - case server_reply::data_type::html: - con->send(message.data, websocketpp::frame::opcode::TEXT); - break; - default: - con->send(message.data, websocketpp::frame::opcode::BINARY); - break; - } - } - - void send_message(connection_handler hdl, const rapidjson::StringBuffer& message) - { - auto con = m_server->get_con_from_hdl(hdl); - con->send(message.GetString(), message.GetSize(), websocketpp::frame::opcode::text); - } - - void send_binary_message(connection_handler hdl, const std::string& message) - { - auto con = m_server->get_con_from_hdl(hdl); - con->send(message.data(), message.size(), websocketpp::frame::opcode::binary); - } - - void send_binary_message(connection_handler hdl, std::string_view message) - { - auto con = m_server->get_con_from_hdl(hdl); - con->send(message.data(), message.size(), websocketpp::frame::opcode::binary); - } - - server_t& impl() { return *m_server; } - -protected: - std::shared_ptr m_server; - ossia::net::network_context_ptr m_context; - bool m_owns_context{}; -}; -} diff --git a/src/ossia/network/sockets/websocket_server_beast.cpp b/src/ossia/network/sockets/websocket_server_beast.cpp new file mode 100644 index 00000000000..fb1b77cdbc8 --- /dev/null +++ b/src/ossia/network/sockets/websocket_server_beast.cpp @@ -0,0 +1,526 @@ +#if !defined(__EMSCRIPTEN__) +#include + +#include + +#include +#include + +namespace ossia::net +{ + +/// beast_ws_connection implementation + +beast_ws_connection::beast_ws_connection( + boost::asio::ip::tcp::socket&& socket, websocket_server_beast& server) + : m_ws{std::move(socket)} + , m_server{server} +{ + auto& tcp = boost::beast::get_lowest_layer(m_ws); + tcp.expires_never(); + boost::asio::ip::tcp::no_delay opt(true); + try + { + tcp.socket().set_option(opt); + } + catch(...) + { + } +} + +beast_ws_connection::~beast_ws_connection() = default; + +void beast_ws_connection::run() +{ + // Start by reading an HTTP request to determine if this is + // a WebSocket upgrade or a plain HTTP request. + do_read_http(); +} + +void beast_ws_connection::send_text(const std::string& msg) +{ + enqueue_write(true, msg); +} + +void beast_ws_connection::send_text(const char* data, std::size_t sz) +{ + enqueue_write(true, std::string(data, sz)); +} + +void beast_ws_connection::send_binary(std::string_view msg) +{ + enqueue_write(false, std::string(msg)); +} + +void beast_ws_connection::enqueue_write(bool text, std::string data) +{ + // Sends originate on the producer thread (device/score), but a beast + // websocket::stream is not thread-safe: it must never be written from a + // thread other than the one running its strand, and never concurrently + // with the in-flight async_read. Marshal every send onto the connection + // strand and serialize them through a queue so that only one async_write + // is ever in flight, and the payload (copied here) outlives the operation. + boost::asio::dispatch( + m_ws.get_executor(), + [self = shared_from_this(), text, data = std::move(data)]() mutable { + const bool idle = self->m_write_queue.empty(); + self->m_write_queue.emplace_back(text, std::move(data)); + if(idle) + self->do_write(); + }); +} + +void beast_ws_connection::do_write() +{ + m_ws.text(m_write_queue.front().first); + m_ws.async_write( + boost::asio::buffer(m_write_queue.front().second), + [self = shared_from_this()](boost::beast::error_code ec, std::size_t bytes) { + self->on_write(ec, bytes); + }); +} + +void beast_ws_connection::on_write(boost::beast::error_code ec, std::size_t) +{ + if(ec) + { + m_server.remove_connection(shared_from_this()); + return; + } + + m_write_queue.pop_front(); + if(!m_write_queue.empty()) + do_write(); +} + +void beast_ws_connection::close() +{ + // close() is called from the producer thread (server teardown). The stream + // is shared with the in-flight async_read, so do the actual close on the + // strand. Shutting the socket down aborts the pending read, which tears the + // connection down through on_read_ws. + boost::asio::dispatch(m_ws.get_executor(), [self = shared_from_this()] { + boost::beast::error_code ec; + auto& tcp = boost::beast::get_lowest_layer(self->m_ws); + tcp.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + tcp.socket().close(ec); + }); +} + +boost::asio::ip::tcp::socket& beast_ws_connection::tcp_socket() +{ + return boost::beast::get_lowest_layer(m_ws).socket(); +} + +void beast_ws_connection::do_read_http() +{ + m_buffer.clear(); + m_http_req = {}; + + boost::beast::http::async_read( + boost::beast::get_lowest_layer(m_ws), m_buffer, m_http_req, + [self = shared_from_this()](boost::beast::error_code ec, std::size_t bytes) { + self->on_read_http(ec, bytes); + }); +} + +void beast_ws_connection::on_read_http(boost::beast::error_code ec, std::size_t) +{ + if(ec) + return; + + // Check if this is a WebSocket upgrade request + if(boost::beast::websocket::is_upgrade(m_http_req)) + { + m_is_websocket = true; + do_accept_ws(); + return; + } + + // Plain HTTP request — handle it like websocketpp did: + // treat as a TEXT message with the URI as payload, send back the reply. + if(m_server.m_on_message) + { + try + { + auto resource = std::string(m_http_req.target()); + auto reply = m_server.m_on_message( + shared_from_this(), ws_opcode::text, resource); + + namespace http = boost::beast::http; + http::response res{http::status::ok, m_http_req.version()}; + res.set(http::field::server, "ossia"); + res.set(http::field::access_control_allow_origin, "*"); + res.set(http::field::connection, "close"); + + switch(reply.type) + { + case server_reply::data_type::json: + res.set(http::field::content_type, "application/json; charset=utf-8"); + break; + case server_reply::data_type::html: + res.set(http::field::content_type, "text/html; charset=utf-8"); + break; + default: + break; + } + + res.body() = std::move(reply.data); + res.prepare_payload(); + + boost::beast::error_code write_ec; + http::write(boost::beast::get_lowest_layer(m_ws), res, write_ec); + } + catch(const std::exception& e) + { + namespace http = boost::beast::http; + http::response res{ + http::status::internal_server_error, m_http_req.version()}; + res.set(http::field::server, "ossia"); + res.set(http::field::connection, "close"); + res.prepare_payload(); + + boost::beast::error_code write_ec; + http::write(boost::beast::get_lowest_layer(m_ws), res, write_ec); + } + } + + // HTTP connection: close after response (no keep-alive for OSCQuery) + boost::beast::error_code close_ec; + boost::beast::get_lowest_layer(m_ws).socket().shutdown( + boost::asio::ip::tcp::socket::shutdown_send, close_ec); +} + +void beast_ws_connection::do_accept_ws() +{ + m_ws.set_option( + boost::beast::websocket::stream_base::timeout::suggested( + boost::beast::role_type::server)); + + m_ws.async_accept( + m_http_req, + [self = shared_from_this()](boost::beast::error_code ec) { + self->on_accept_ws(ec); + }); +} + +void beast_ws_connection::on_accept_ws(boost::beast::error_code ec) +{ + if(ec) + return; + + // Notify server of new connection + m_server.add_connection(shared_from_this()); + + // Start reading messages + do_read_ws(); +} + +void beast_ws_connection::do_read_ws() +{ + m_buffer.clear(); + m_ws.async_read( + m_buffer, + [self = shared_from_this()](boost::beast::error_code ec, std::size_t bytes) { + self->on_read_ws(ec, bytes); + }); +} + +void beast_ws_connection::on_read_ws(boost::beast::error_code ec, std::size_t) +{ + if(ec) + { + // Connection closed or error + m_server.remove_connection(shared_from_this()); + return; + } + + if(m_server.m_on_message) + { + auto opcode = m_ws.got_text() ? ws_opcode::text : ws_opcode::binary; + auto data = boost::beast::buffers_to_string(m_buffer.data()); + + try + { + auto reply = m_server.m_on_message(shared_from_this(), opcode, data); + + if(!reply.data.empty()) + { + switch(reply.type) + { + case server_reply::data_type::json: + case server_reply::data_type::html: + send_text(reply.data); + break; + default: + send_binary(reply.data); + break; + } + } + } + catch(const std::exception& e) + { + ossia::logger().error("Error in WS message handling: {}", e.what()); + } + catch(...) + { + ossia::logger().error("Error in WS message handling"); + } + } + + // Continue reading + do_read_ws(); +} + +/// websocket_server_beast implementation + +websocket_server_beast::websocket_server_beast(ossia::net::network_context_ptr ctx) + : m_context{std::move(ctx)} + , m_acceptor{boost::asio::make_strand(m_context->context)} +{ +} + +websocket_server_beast::~websocket_server_beast() +{ + stop(); +} + +void websocket_server_beast::listen(uint16_t port) +{ + // Listen on IPv6 with dual-stack (accepts both IPv4 and IPv6) + boost::asio::ip::tcp::endpoint endpoint{boost::asio::ip::tcp::v6(), port}; + + boost::beast::error_code ec; + m_acceptor.open(endpoint.protocol(), ec); + if(ec) + return; + + m_acceptor.set_option(boost::asio::socket_base::reuse_address(true), ec); + if(ec) + return; + + // Allow both IPv4 and IPv6 connections on this socket + m_acceptor.set_option(boost::asio::ip::v6_only(false), ec); + // Not fatal if this fails (some OS don't support dual-stack) + + m_acceptor.bind(endpoint, ec); + if(ec) + { + // Fallback to IPv4-only if dual-stack bind fails + m_acceptor.close(ec); + endpoint = boost::asio::ip::tcp::endpoint{boost::asio::ip::tcp::v4(), port}; + m_acceptor.open(endpoint.protocol(), ec); + if(ec) + return; + m_acceptor.set_option(boost::asio::socket_base::reuse_address(true), ec); + m_acceptor.bind(endpoint, ec); + if(ec) + return; + } + + m_acceptor.listen(boost::asio::socket_base::max_listen_connections, ec); + if(ec) + return; + + do_accept(); +} + +void websocket_server_beast::run() +{ + m_context->context.run(); +} + +void websocket_server_beast::stop() +{ + boost::beast::error_code ec; + if(m_acceptor.is_open()) + m_acceptor.close(ec); + + std::lock_guard lock{m_connections_mutex}; + for(auto& conn : m_connections) + conn->close(); + m_connections.clear(); +} + +void websocket_server_beast::close(ws_connection_handle hdl) +{ + if(auto conn = find_connection(hdl)) + conn->close(); +} + +void websocket_server_beast::set_open_handler(ws_open_handler h) +{ + m_on_open = std::move(h); +} + +void websocket_server_beast::set_close_handler(ws_close_handler h) +{ + m_on_close = std::move(h); +} + +void websocket_server_beast::set_message_handler(ws_server_message_handler h) +{ + m_on_message = std::move(h); +} + +std::string websocket_server_beast::get_remote_ip(const ws_connection_handle& hdl) +{ + if(auto conn = find_connection(hdl)) + { + try + { + return conn->tcp_socket().remote_endpoint().address().to_string(); + } + catch(...) + { + } + } + return {}; +} + +std::string websocket_server_beast::get_remote_endpoint(const ws_connection_handle& hdl) +{ + if(auto conn = find_connection(hdl)) + { + try + { + auto ep = conn->tcp_socket().remote_endpoint(); + return ep.address().to_string() + ":" + std::to_string(ep.port()); + } + catch(...) + { + } + } + return {}; +} + +std::string websocket_server_beast::get_local_ip(const ws_connection_handle& hdl) +{ + if(auto conn = find_connection(hdl)) + { + try + { + return conn->tcp_socket().local_endpoint().address().to_string(); + } + catch(...) + { + } + } + return {}; +} + +void websocket_server_beast::send_message( + ws_connection_handle hdl, const std::string& message) +{ + if(auto conn = find_connection(hdl)) + conn->send_text(message); +} + +void websocket_server_beast::send_message( + ws_connection_handle hdl, const server_reply& message) +{ + if(auto conn = find_connection(hdl)) + { + switch(message.type) + { + case server_reply::data_type::json: + case server_reply::data_type::html: + conn->send_text(message.data); + break; + default: + conn->send_binary(message.data); + break; + } + } +} + +void websocket_server_beast::send_message( + ws_connection_handle hdl, const rapidjson::StringBuffer& message) +{ + if(auto conn = find_connection(hdl)) + conn->send_text(message.GetString(), message.GetSize()); +} + +void websocket_server_beast::send_binary_message( + ws_connection_handle hdl, std::string_view message) +{ + if(auto conn = find_connection(hdl)) + conn->send_binary(message); +} + +void websocket_server_beast::do_accept() +{ + m_acceptor.async_accept( + boost::asio::make_strand(m_context->context), + [this](boost::beast::error_code ec, boost::asio::ip::tcp::socket socket) { + on_accept(ec, std::move(socket)); + }); +} + +void websocket_server_beast::on_accept( + boost::beast::error_code ec, boost::asio::ip::tcp::socket socket) +{ + if(ec) + { + // The acceptor was closed: stop the loop. Any other (transient) error + // must not kill the listener — keep accepting. + if(ec == boost::asio::error::operation_aborted) + return; + ossia::logger().error("WS accept error: {}", ec.message()); + do_accept(); + return; + } + + // Create connection and start reading + auto conn = std::make_shared(std::move(socket), *this); + conn->run(); + + // Accept next connection + do_accept(); +} + +void websocket_server_beast::add_connection(std::shared_ptr conn) +{ + ws_connection_handle hdl = conn; + { + std::lock_guard lock{m_connections_mutex}; + m_connections.insert(std::move(conn)); + } + + if(m_on_open) + m_on_open(hdl); +} + +void websocket_server_beast::remove_connection(std::shared_ptr conn) +{ + ws_connection_handle hdl = conn; + + { + std::lock_guard lock{m_connections_mutex}; + m_connections.erase(conn); + } + + if(m_on_close) + m_on_close(hdl); +} + +std::shared_ptr +websocket_server_beast::find_connection(const ws_connection_handle& hdl) +{ + auto sp = hdl.lock(); + if(!sp) + return nullptr; + + // Return a shared_ptr (not a raw pointer): the caller dereferences the + // connection on the producer thread while the io thread may concurrently + // remove and destroy it. Holding a strong ref keeps it alive for the call. + std::lock_guard lock{m_connections_mutex}; + auto it = m_connections.find( + std::static_pointer_cast(sp)); + if(it != m_connections.end()) + return *it; + return nullptr; +} + +} +#endif diff --git a/src/ossia/network/sockets/websocket_server_beast.hpp b/src/ossia/network/sockets/websocket_server_beast.hpp new file mode 100644 index 00000000000..7d1c3c0c4b9 --- /dev/null +++ b/src/ossia/network/sockets/websocket_server_beast.hpp @@ -0,0 +1,112 @@ +#pragma once +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ossia::net +{ + +class websocket_server_beast; + +/// A single WebSocket connection managed by the beast server. +class beast_ws_connection : public std::enable_shared_from_this +{ + friend class websocket_server_beast; + +public: + explicit beast_ws_connection( + boost::asio::ip::tcp::socket&& socket, websocket_server_beast& server); + + ~beast_ws_connection(); + + void run(); + void send_text(const std::string& msg); + void send_text(const char* data, std::size_t sz); + void send_binary(std::string_view msg); + void close(); + + boost::asio::ip::tcp::socket& tcp_socket(); + +private: + void do_read_http(); + void on_read_http(boost::beast::error_code ec, std::size_t bytes); + void do_accept_ws(); + void on_accept_ws(boost::beast::error_code ec); + void do_read_ws(); + void on_read_ws(boost::beast::error_code ec, std::size_t bytes); + + // Marshal a send onto the connection strand and serialize writes. + void enqueue_write(bool text, std::string data); + void do_write(); + void on_write(boost::beast::error_code ec, std::size_t bytes); + + boost::beast::websocket::stream m_ws; + boost::beast::flat_buffer m_buffer; + boost::beast::http::request m_http_req; + websocket_server_beast& m_server; + bool m_is_websocket{false}; + + // Outbound queue, only touched on the strand. {is_text, payload}. + std::deque> m_write_queue; +}; + +/// Beast-based WebSocket + HTTP server implementing websocket_server_interface. +class OSSIA_EXPORT websocket_server_beast final : public websocket_server_interface +{ + friend class beast_ws_connection; + +public: + explicit websocket_server_beast(ossia::net::network_context_ptr ctx); + ~websocket_server_beast() override; + + void listen(uint16_t port) override; + void run() override; + void stop() override; + void close(ws_connection_handle hdl) override; + + void set_open_handler(ws_open_handler h) override; + void set_close_handler(ws_close_handler h) override; + void set_message_handler(ws_server_message_handler h) override; + + std::string get_remote_ip(const ws_connection_handle& hdl) override; + std::string get_remote_endpoint(const ws_connection_handle& hdl) override; + std::string get_local_ip(const ws_connection_handle& hdl) override; + + void send_message(ws_connection_handle hdl, const std::string& message) override; + void send_message(ws_connection_handle hdl, const server_reply& message) override; + void + send_message(ws_connection_handle hdl, const rapidjson::StringBuffer& message) override; + void send_binary_message(ws_connection_handle hdl, std::string_view message) override; + +private: + void do_accept(); + void on_accept(boost::beast::error_code ec, boost::asio::ip::tcp::socket socket); + void add_connection(std::shared_ptr conn); + void remove_connection(std::shared_ptr conn); + + std::shared_ptr find_connection(const ws_connection_handle& hdl); + + ossia::net::network_context_ptr m_context; + boost::asio::ip::tcp::acceptor m_acceptor; + std::set> m_connections; + std::mutex m_connections_mutex; + + ws_open_handler m_on_open; + ws_close_handler m_on_close; + ws_server_message_handler m_on_message; +}; + +} diff --git a/src/ossia/network/sockets/websocket_server_interface.cpp b/src/ossia/network/sockets/websocket_server_interface.cpp new file mode 100644 index 00000000000..dd2e598838d --- /dev/null +++ b/src/ossia/network/sockets/websocket_server_interface.cpp @@ -0,0 +1,6 @@ +#include + +namespace ossia::net +{ +websocket_server_interface::~websocket_server_interface() = default; +} diff --git a/src/ossia/network/sockets/websocket_server_interface.hpp b/src/ossia/network/sockets/websocket_server_interface.hpp new file mode 100644 index 00000000000..74eeab95ea7 --- /dev/null +++ b/src/ossia/network/sockets/websocket_server_interface.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace rapidjson +{ +class CrtAllocator; +template +class MemoryPoolAllocator; +template +class GenericStringBuffer; +template +struct UTF8; +using StringBuffer = GenericStringBuffer, CrtAllocator>; +} + +namespace ossia::net +{ + +/// Callback: (handle, opcode, payload) → reply +using ws_server_message_handler = std::function< + server_reply(const ws_connection_handle&, ws_opcode, const std::string&)>; + +using ws_open_handler = std::function; +using ws_close_handler = std::function; + +/// Abstract interface for a WebSocket + HTTP server. +/// +/// Both plain beast and Socket.IO server implementations +/// derive from this interface. +class OSSIA_EXPORT websocket_server_interface +{ +public: + virtual ~websocket_server_interface(); + + virtual void listen(uint16_t port) = 0; + virtual void run() = 0; + virtual void stop() = 0; + virtual void close(ws_connection_handle hdl) = 0; + + virtual void set_open_handler(ws_open_handler) = 0; + virtual void set_close_handler(ws_close_handler) = 0; + + /// The message handler is called for both WebSocket messages and HTTP requests. + /// For HTTP requests, the opcode is ws_opcode::text and the payload is the URI. + /// The returned server_reply is sent back to the client. + virtual void set_message_handler(ws_server_message_handler) = 0; + + /// Get the remote IP address of a connected client (e.g. "192.168.1.10"). + virtual std::string get_remote_ip(const ws_connection_handle& hdl) = 0; + + /// Get the remote endpoint string (e.g. "192.168.1.10:54321"). + virtual std::string get_remote_endpoint(const ws_connection_handle& hdl) = 0; + + /// Get the local IP address as seen by a connected client. + virtual std::string get_local_ip(const ws_connection_handle& hdl) = 0; + + virtual void send_message(ws_connection_handle hdl, const std::string& message) = 0; + virtual void + send_message(ws_connection_handle hdl, const server_reply& message) = 0; + virtual void + send_message(ws_connection_handle hdl, const rapidjson::StringBuffer& message) = 0; + virtual void + send_binary_message(ws_connection_handle hdl, std::string_view message) = 0; +}; + +} diff --git a/src/ossia/network/sockets/websocket_simple_beast.hpp b/src/ossia/network/sockets/websocket_simple_beast.hpp new file mode 100644 index 00000000000..edd1a65aaba --- /dev/null +++ b/src/ossia/network/sockets/websocket_simple_beast.hpp @@ -0,0 +1,120 @@ +#pragma once +#include +#include +#include +#include +#include +#if !defined(__EMSCRIPTEN__) +#include +#endif + +#include + +namespace ossia::net +{ + +/// Duck-typed simple websocket client for use with osc_generic_client_protocol. +/// Replaces websocket_simple_client (wspp-based) with beast-based implementation. +struct websocket_simple_client_beast +{ + websocket_simple_client_beast( + const ws_client_configuration& conf, boost::asio::io_context& ctx) + : m_context{ctx} + , m_host{conf.url} + { + } + + void connect() { } + + template + void receive(F onMessage) + { + m_client = make_websocket_client( + m_context, + [handler = std::move(onMessage)]( + const ws_connection_handle&, ws_opcode, std::string& data) { + handler((const unsigned char*)data.data(), data.size()); + }); + + m_client->connect(m_host); + } + + void write(const char* data, std::size_t sz) + { + if(m_client) + m_client->send_binary_message(std::string_view{data, sz}); + } + + void close() + { + if(m_client && m_client->connected()) + m_client->stop(); + } + + bool connected() const { return m_client && m_client->connected(); } + + Nano::Signal on_open; + Nano::Signal on_close; + Nano::Signal on_fail; + + boost::asio::io_context& m_context; + std::string m_host; + std::unique_ptr m_client; +}; + +/// Duck-typed simple websocket server for use with osc_generic_server_protocol. +/// Replaces websocket_simple_server (wspp-based) with beast-based implementation. +struct websocket_simple_server_beast +{ + websocket_simple_server_beast( + const ws_server_configuration& conf, ossia::net::network_context_ptr ctx) + : m_server{std::move(ctx)} + , m_port{conf.port} + { + m_server.set_open_handler([this](ws_connection_handle hdl) { + std::lock_guard lock{m_listeners_mutex}; + m_listeners.push_back(hdl.lock()); + }); + m_server.set_close_handler([this](ws_connection_handle hdl) { + std::lock_guard lock{m_listeners_mutex}; + ossia::remove_erase(m_listeners, hdl.lock()); + }); + } + + template + void listen(F onMessage) + { + m_server.set_message_handler( + [handler = std::move(onMessage)]( + const ws_connection_handle&, ws_opcode, const std::string& data) { + handler((const unsigned char*)data.data(), data.size()); + return server_reply{}; + }); + + m_server.listen(m_port); + } + + void write(const char* data, std::size_t sz) + { + std::string_view dat{data, sz}; + // The listener list is mutated by the open/close handlers on the io + // thread; this runs on the producer thread, so guard the iteration. + std::lock_guard lock{m_listeners_mutex}; + for(auto& listener : m_listeners) + m_server.send_binary_message(listener, dat); + } + + void close() { m_server.stop(); } + + websocket_server_beast m_server; + std::mutex m_listeners_mutex; + std::vector> m_listeners; + int m_port{}; +}; + +// Backwards-compatibility aliases for the old websocketpp-based names, so that +// downstream code (e.g. score puppets) keeps building during the transition. +using websocket_simple_client = websocket_simple_client_beast; +using websocket_simple_server = websocket_simple_server_beast; + +} diff --git a/src/ossia/protocols/osc/osc_factory.cpp b/src/ossia/protocols/osc/osc_factory.cpp index 91be337a7e6..3d703f1f4df 100644 --- a/src/ossia/protocols/osc/osc_factory.cpp +++ b/src/ossia/protocols/osc/osc_factory.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -128,7 +129,7 @@ make_osc_protocol_impl(network_context_ptr&& ctx, osc_protocol_configuration&& c -> std::unique_ptr { return std::make_unique< - osc_generic_client_protocol>( + osc_generic_client_protocol>( std::move(ctx), conf); } @@ -257,7 +258,7 @@ make_osc_protocol_impl(network_context_ptr&& ctx, osc_protocol_configuration&& c -> std::unique_ptr { return std::make_unique< - osc_generic_server_protocol>( + osc_generic_server_protocol>( std::move(ctx), conf); } } vis{std::move(ctx), config}; diff --git a/src/ossia/protocols/oscquery/oscquery_client_asio.hpp b/src/ossia/protocols/oscquery/oscquery_client_asio.hpp index 0c1cfb3b2b3..9127128a2a0 100644 --- a/src/ossia/protocols/oscquery/oscquery_client_asio.hpp +++ b/src/ossia/protocols/oscquery/oscquery_client_asio.hpp @@ -6,14 +6,15 @@ #include #include #include -#include +#include +#include #include namespace ossia::oscquery_asio { struct oscquery_client { - ossia::net::websocket_server::connection_handler connection; + ossia::net::ws_connection_handle connection; mutex_t listeningMutex; string_map listening TS_GUARDED_BY(listeningMutex); @@ -28,7 +29,7 @@ struct oscquery_client oscquery_client(oscquery_client&& other) noexcept = delete; oscquery_client& operator=(oscquery_client&& other) noexcept = delete; - explicit oscquery_client(ossia::net::websocket_server::connection_handler h) + explicit oscquery_client(ossia::net::ws_connection_handle h) : connection{std::move(h)} { } @@ -48,7 +49,7 @@ struct oscquery_client listening.erase(path); } - bool operator==(const ossia::net::websocket_server::connection_handler& h) const + bool operator==(const ossia::net::ws_connection_handle& h) const { return !connection.expired() && connection.lock() == h.lock(); } diff --git a/src/ossia/protocols/oscquery/oscquery_fwd.hpp b/src/ossia/protocols/oscquery/oscquery_fwd.hpp index 1b02bb15763..8a1849c1bc2 100644 --- a/src/ossia/protocols/oscquery/oscquery_fwd.hpp +++ b/src/ossia/protocols/oscquery/oscquery_fwd.hpp @@ -25,7 +25,7 @@ class IpEndpointName; namespace ossia::net { -class websocket_client; +class websocket_client_interface; struct parameter_data; struct network_context; using network_context_ptr = std::shared_ptr; diff --git a/src/ossia/protocols/oscquery/oscquery_mirror_asio.cpp b/src/ossia/protocols/oscquery/oscquery_mirror_asio.cpp index 8e067c35c8f..a8196b7d32c 100644 --- a/src/ossia/protocols/oscquery/oscquery_mirror_asio.cpp +++ b/src/ossia/protocols/oscquery/oscquery_mirror_asio.cpp @@ -20,7 +20,7 @@ #include #include #include -#include +#include #include namespace ossia::oscquery_asio @@ -383,16 +383,17 @@ void oscquery_mirror_asio_protocol::start_websockets() if(m_protocol_to_use == http) return; - m_websocketClient = std::make_unique( - m_ctx->context, [this]( - const connection_handler& hdl, - websocketpp::frame::opcode::value op, std::string& msg) { + m_websocketClient = ossia::net::make_websocket_client( + m_ctx->context, + [this]( + const ossia::net::ws_connection_handle& hdl, ossia::net::ws_opcode op, + std::string& msg) { switch(op) { - case websocketpp::frame::opcode::value::TEXT: + case ossia::net::ws_opcode::text: this->on_text_ws_message(hdl, msg); break; - case websocketpp::frame::opcode::value::BINARY: + case ossia::net::ws_opcode::binary: this->on_binary_ws_message(hdl, msg); break; default: diff --git a/src/ossia/protocols/oscquery/oscquery_mirror_asio.hpp b/src/ossia/protocols/oscquery/oscquery_mirror_asio.hpp index 10cc826d58c..9d0f4b1204b 100644 --- a/src/ossia/protocols/oscquery/oscquery_mirror_asio.hpp +++ b/src/ossia/protocols/oscquery/oscquery_mirror_asio.hpp @@ -46,7 +46,7 @@ class OSSIA_EXPORT oscquery_mirror_asio_protocol final : public ossia::net::prot bool ws_connected() const noexcept { return m_hasWS; } bool osc_connected() const noexcept { return bool(m_oscSender); } osc_sender_impl& osc_sender() const noexcept { return *m_oscSender; } - ossia::net::websocket_client& ws_client() const noexcept { return *m_websocketClient; } + ossia::net::websocket_client_interface& ws_client() const noexcept { return *m_websocketClient; } /** * @brief Request a new node from the server @@ -135,7 +135,7 @@ class OSSIA_EXPORT oscquery_mirror_asio_protocol final : public ossia::net::prot std::unique_ptr m_oscSender; std::unique_ptr m_oscServer; - std::unique_ptr m_websocketClient; + std::unique_ptr m_websocketClient; std::shared_ptr m_async_state; std::atomic_bool m_hasWS{}; diff --git a/src/ossia/protocols/oscquery/oscquery_mirror_asio_dense.cpp b/src/ossia/protocols/oscquery/oscquery_mirror_asio_dense.cpp index 51c7f1b0957..472a703ae20 100644 --- a/src/ossia/protocols/oscquery/oscquery_mirror_asio_dense.cpp +++ b/src/ossia/protocols/oscquery/oscquery_mirror_asio_dense.cpp @@ -21,7 +21,7 @@ #include #include #include -#include +#include #include #include diff --git a/src/ossia/protocols/oscquery/oscquery_server_asio.cpp b/src/ossia/protocols/oscquery/oscquery_server_asio.cpp index 89469505b71..2faf9d84a8a 100644 --- a/src/ossia/protocols/oscquery/oscquery_server_asio.cpp +++ b/src/ossia/protocols/oscquery/oscquery_server_asio.cpp @@ -27,7 +27,7 @@ #include #include #include -#include +#include #include namespace ossia::oscquery_asio { @@ -63,22 +63,22 @@ oscquery_server_protocol_base::oscquery_server_protocol_base( bool forceWS) : protocol_base{flags{SupportsMultiplex}} , m_context{std::move(ctx)} - , m_websocketServer{std::make_unique(m_context)} + , m_websocketServer{std::make_unique(m_context)} , m_oscConf{conf} , m_wsPort{ws_port} , m_forceWS{forceWS} { m_clients.reserve(2); m_websocketServer->set_open_handler( - [&](connection_handler hdl) { on_connectionOpen(hdl); }); + [&](ossia::net::ws_connection_handle hdl) { on_connectionOpen(hdl); }); m_websocketServer->set_close_handler( - [&](connection_handler hdl) { on_connectionClosed(hdl); }); + [&](ossia::net::ws_connection_handle hdl) { on_connectionClosed(hdl); }); m_websocketServer->set_message_handler( - [&](const connection_handler& hdl, websocketpp::frame::opcode::value op, + [&](const ossia::net::ws_connection_handle& hdl, ossia::net::ws_opcode op, const std::string& str) { switch(op) { - case websocketpp::frame::opcode::value::TEXT: { + case ossia::net::ws_opcode::text: { auto res = on_text_ws_message(hdl, str); if(!res.data.empty() && res.type != ossia::net::server_reply::data_type::binary @@ -87,7 +87,7 @@ oscquery_server_protocol_base::oscquery_server_protocol_base( return res; } - case websocketpp::frame::opcode::value::BINARY: { + case ossia::net::ws_opcode::binary: { return on_binary_ws_message(hdl, str); } default: @@ -134,8 +134,8 @@ void oscquery_server_protocol_base::request(net::parameter_base&) struct ws_client_socket { - ossia::net::websocket_server& server; - ossia::net::websocket_server::connection_handler& connection; + ossia::net::websocket_server_interface& server; + ossia::net::ws_connection_handle& connection; void send_binary_message(std::string_view s) { @@ -431,8 +431,7 @@ void oscquery_server_protocol_base::stop() auto it = m_clients.begin(); while(it != m_clients.end()) { - auto con = m_websocketServer->impl().get_con_from_hdl((*it)->connection); - con->close(websocketpp::close::status::going_away, "Server shutdown"); + m_websocketServer->close((*it)->connection); it = m_clients.erase(it); } m_clientCount = 0; @@ -574,10 +573,7 @@ catch(...) void oscquery_server_protocol_base::on_connectionOpen(const connection_handler& hdl) try { - auto con = m_websocketServer->impl().get_con_from_hdl(hdl); - - boost::asio::ip::tcp::socket& sock = con->get_raw_socket(); - auto ip = sock.remote_endpoint().address().to_string(); + auto ip = m_websocketServer->get_remote_ip(hdl); if(ip.substr(0, 7) == "::ffff:") ip = ip.substr(7); @@ -589,7 +585,7 @@ try m_clientCount++; } - onClientConnected(con->get_remote_endpoint()); + onClientConnected(m_websocketServer->get_remote_endpoint(hdl)); } catch(const std::exception& e) { @@ -612,8 +608,7 @@ void oscquery_server_protocol_base::on_connectionClosed(const connection_handler } } - auto con = m_websocketServer->impl().get_con_from_hdl(hdl); - onClientDisconnected(con->get_remote_endpoint()); + onClientDisconnected(m_websocketServer->get_remote_endpoint(hdl)); } void oscquery_server_protocol_base::on_nodeCreated(const net::node_base& n) diff --git a/src/ossia/protocols/oscquery/oscquery_server_asio.hpp b/src/ossia/protocols/oscquery/oscquery_server_asio.hpp index 6f2e386d2e6..693bec88bf1 100644 --- a/src/ossia/protocols/oscquery/oscquery_server_asio.hpp +++ b/src/ossia/protocols/oscquery/oscquery_server_asio.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -28,7 +29,7 @@ namespace ossia { namespace net { -class websocket_server; +class websocket_server_interface; } namespace oscquery { @@ -134,7 +135,7 @@ class OSSIA_EXPORT oscquery_server_protocol_base : public ossia::net::protocol_b ossia::net::network_context_ptr m_context; - std::unique_ptr m_websocketServer; + std::unique_ptr m_websocketServer; net::zeroconf_server m_zeroconfServerWS; diff --git a/src/ossia/protocols/socketio/boost_json_impl.cpp b/src/ossia/protocols/socketio/boost_json_impl.cpp new file mode 100644 index 00000000000..9627f94fcb9 --- /dev/null +++ b/src/ossia/protocols/socketio/boost_json_impl.cpp @@ -0,0 +1 @@ +#include diff --git a/src/ossia/protocols/socketio/socketio_client.cpp b/src/ossia/protocols/socketio/socketio_client.cpp new file mode 100644 index 00000000000..5f1a29da2f3 --- /dev/null +++ b/src/ossia/protocols/socketio/socketio_client.cpp @@ -0,0 +1,378 @@ +// Socket.IO uses Boost.Beast TCP streams / coroutines which are not available +// in the Emscripten/browser sandbox. +#if !defined(__EMSCRIPTEN__) +#include + +#include + +namespace ossia::net +{ + +socketio_client::socketio_client(boost::asio::io_context& ctx) + : m_context{ctx} + , m_write_channel{ctx} +{ +} + +socketio_client::socketio_client( + boost::asio::io_context& ctx, ws_client_message_handler handler) + : m_context{ctx} + , m_write_channel{ctx} + , m_on_message{std::move(handler)} +{ +} + +socketio_client::~socketio_client() +{ + if(m_open) + stop(); +} + +void socketio_client::connect(const std::string& uri) +{ + auto parsed = parse_websocket_uri(uri); + m_host = parsed.host; + m_connected = true; + + co_spawn_detached(run_session( + std::move(parsed.host), std::move(parsed.port), std::move(parsed.path))); +} + +void socketio_client::connect_and_run(const std::string& uri) +{ + connect(uri); + m_context.run(); + m_connected = false; +} + +void socketio_client::stop() +{ + if(m_open && m_ws) + { + // Send Socket.IO DISCONNECT then Engine.IO CLOSE + enqueue_write(std::string("41")); // EIO_MESSAGE + SIO_DISCONNECT + } + + std::lock_guard lock{m_mutex}; + if(m_ws && m_ws->is_open()) + { + boost::beast::error_code ec; + m_ws->close(boost::beast::websocket::close_code::normal, ec); + } + m_open = false; + m_connected = false; +} + +bool socketio_client::connected() const +{ + return m_open; +} + +void socketio_client::send_message(const std::string& request) +{ + if(!m_open) + return; + // Wrap as Socket.IO EVENT: "42" + JSON array with the message + // For OSCQuery, the message is already JSON, wrap it as: 42["message",] + std::string packet; + packet.reserve(4 + request.size()); + packet += EIO_MESSAGE; + packet += SIO_EVENT; + packet += request; + enqueue_write(std::move(packet)); +} + +void socketio_client::send_message(const rapidjson::StringBuffer& request) +{ + send_message(std::string(request.GetString(), request.GetSize())); +} + +void socketio_client::send_binary_message(std::string_view request) +{ + if(!m_open) + return; + // Socket.IO BINARY_EVENT: "451-[...binary placeholder...]" + binary frame + // For simplicity, send as a text event with base64 or just as a raw + // EIO_MESSAGE with binary flag. + // Actually, for OSCQuery compatibility, we send binary data as a websocket + // binary frame wrapped in Engine.IO MESSAGE: + std::string packet; + packet.reserve(2 + request.size()); + packet += EIO_MESSAGE; + packet += SIO_BINARY_EVENT; + packet += request; + enqueue_write(std::move(packet)); +} + +void socketio_client::enqueue_write(std::string msg) +{ + boost::asio::co_spawn( + m_context, + [this, msg = std::move(msg)]() -> awaitable { + co_await m_write_channel.async_send( + boost::system::error_code{}, msg, boost::asio::use_awaitable); + }, + [](std::exception_ptr) {}); +} + +socketio_client::awaitable socketio_client::run_session( + std::string host, std::string port, std::string) +{ + static constexpr auto init_timeout = std::chrono::seconds(5); + auto executor = co_await boost::asio::this_coro::executor; + auto resolver = boost::asio::ip::tcp::resolver{executor}; + + m_read_buffer.reserve(1000000); + auto const results = co_await resolver.async_resolve(host, port); + + m_ws = std::make_unique(executor); + m_ws->set_option(boost::beast::websocket::stream_base::timeout::suggested( + boost::beast::role_type::client)); + m_ws->set_option( + boost::beast::websocket::stream_base::decorator( + [](boost::beast::websocket::request_type& req) { + req.set(boost::beast::http::field::user_agent, "ossia"); + })); + + auto& stream = boost::beast::get_lowest_layer(*m_ws); + + // Step 1: TCP connect + stream.expires_after(init_timeout); + co_await stream.async_connect(results); + + // Step 2: Engine.IO handshake via HTTP polling + { + stream.expires_after(init_timeout); + auto rep = co_await http_request( + boost::beast::http::verb::get, stream, + "/socket.io/?EIO=4&transport=polling"); + if(!parse_engineio_open(rep, m_config)) + { + on_fail(); + co_return; + } + } + + // Step 3: Socket.IO CONNECT request via HTTP polling + { + stream.expires_after(init_timeout); + auto rep = co_await http_request( + boost::beast::http::verb::post, stream, + "/socket.io/?EIO=4&transport=polling&sid=" + m_config.sid, "40"); + if(rep != "ok") + { + on_fail(); + co_return; + } + } + + // Step 4: Socket.IO CONNECT approval via HTTP polling + { + stream.expires_after(init_timeout); + auto rep = co_await http_request( + boost::beast::http::verb::get, stream, + "/socket.io/?EIO=4&transport=polling&sid=" + m_config.sid); + if(!parse_socketio_connect(rep, m_config)) + { + on_fail(); + co_return; + } + } + + // Step 5: WebSocket upgrade + { + stream.expires_after(init_timeout); + co_await m_ws->async_handshake( + m_host, + "/socket.io/?EIO=4&transport=websocket&sid=" + m_config.sid); + + // Probe sequence + stream.expires_after(init_timeout); + co_await m_ws->async_write(boost::asio::buffer("2probe", 6)); + + stream.expires_after(init_timeout); + m_read_buffer.clear(); + co_await m_ws->async_read(m_read_buffer); + if(boost::beast::buffers_to_string(m_read_buffer.data()) != "3probe") + { + on_fail(); + co_return; + } + + // Send upgrade packet + m_read_buffer.clear(); + stream.expires_after(init_timeout); + co_await m_ws->async_write(boost::asio::buffer("5", 1)); + } + + // Connected! + auto& tcp = boost::beast::get_lowest_layer(*m_ws); + tcp.expires_after(m_config.ping_interval + m_config.ping_timeout); + + m_open = true; + on_open(); + + // Start write loop and read loop + co_spawn_detached(write_loop()); + co_await read_loop(); + co_await close_session(); +} + +socketio_client::awaitable socketio_client::http_request( + boost::beast::http::verb v, boost::beast::tcp_stream& stream, + std::string_view target, std::string_view body) +{ + namespace http = boost::beast::http; + http::request req{v, target, 11}; + req.set(http::field::host, m_host); + req.set(http::field::user_agent, "ossia"); // FIXME give end-user control over this + req.set(http::field::connection, "keep-alive"); + if(!body.empty()) + { + req.set(http::field::content_length, std::to_string(body.size())); + req.body() = body; + } + + co_await http::async_write(stream, req); + m_read_buffer.clear(); + http::response res; + co_await http::async_read(stream, m_read_buffer, res); + co_return res.body(); +} + +socketio_client::awaitable socketio_client::read_loop() +{ + for(;;) + { + m_read_buffer.clear(); + auto bytes = co_await m_ws->async_read(m_read_buffer); + if(bytes > 0) + { + auto data = boost::beast::buffers_to_string(m_read_buffer.data()); + if(!process_engineio(data)) + { + ossia::logger().error("socketio_client: error processing message"); + } + } + } +} + +socketio_client::awaitable socketio_client::write_loop() +{ + for(;;) + { + auto str = co_await m_write_channel.async_receive(boost::asio::use_awaitable); + co_await m_ws->async_write(boost::asio::buffer(str.data(), str.size())); + } +} + +socketio_client::awaitable socketio_client::write_pong() +{ + auto& tcp = boost::beast::get_lowest_layer(*m_ws); + tcp.expires_after(m_config.ping_interval + m_config.ping_timeout); + + co_await m_write_channel.async_send( + boost::system::error_code{}, std::string(1, EIO_PONG), + boost::asio::use_awaitable); +} + +socketio_client::awaitable socketio_client::close_session() +{ + m_open = false; + if(m_ws) + { + auto& stream = boost::beast::get_lowest_layer(*m_ws); + boost::beast::error_code ec; + stream.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + } + on_close(); + co_return; +} + +bool socketio_client::process_engineio(std::string_view data) +{ + if(data.empty()) + return false; + + switch(data[0]) + { + case EIO_OPEN: + return false; // Shouldn't happen after handshake + case EIO_CLOSE: + co_spawn_detached(close_session()); + return true; + case EIO_PING: + co_spawn_detached(write_pong()); + return true; + case EIO_PONG: + return true; + case EIO_NOOP: + return true; + case EIO_UPGRADE: + return true; + case EIO_MESSAGE: { + data = data.substr(1); + if(data.empty()) + return false; + dispatch_socketio_message(data); + return true; + } + default: + return true; + } +} + +void socketio_client::dispatch_socketio_message(std::string_view data) +{ + if(data.empty()) + return; + + switch(data[0]) + { + case SIO_CONNECT: + // Already connected + break; + case SIO_DISCONNECT: + co_spawn_detached(close_session()); + break; + case SIO_EVENT: { + data = data.substr(1); + if(!data.empty() && m_on_message) + { + auto str = std::string(data); + ws_connection_handle hdl; + m_on_message(hdl, ws_opcode::text, str); + } + break; + } + case SIO_ACK: { + data = data.substr(1); + // ACKs could be handled here if needed + break; + } + case SIO_BINARY_EVENT: { + data = data.substr(1); + // Skip the attachment count prefix (e.g. "1-") + if(auto dash = data.find('-'); dash != std::string_view::npos) + data = data.substr(dash + 1); + if(!data.empty() && m_on_message) + { + auto str = std::string(data); + ws_connection_handle hdl; + m_on_message(hdl, ws_opcode::binary, str); + } + break; + } + case SIO_BINARY_ACK: + break; + case SIO_CONNECT_ERROR: + ossia::logger().error("socketio_client: connect error"); + co_spawn_detached(close_session()); + break; + default: + break; + } +} + +} +#endif diff --git a/src/ossia/protocols/socketio/socketio_client.hpp b/src/ossia/protocols/socketio/socketio_client.hpp new file mode 100644 index 00000000000..4864aaf78a6 --- /dev/null +++ b/src/ossia/protocols/socketio/socketio_client.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace ossia::net +{ + +/// Socket.IO client implementing websocket_client_interface. +/// +/// Performs the Engine.IO v4 handshake (HTTP polling followed by WebSocket upgrade) +/// and the Socket.IO v5 CONNECT, then presents incoming Socket.IO +/// EVENT/BINARY_EVENT messages through the standard ws_client_message_handler +/// callback interface. Outgoing send_message/send_binary_message calls +/// are wrapped into Socket.IO EVENT/BINARY_EVENT packets. +class OSSIA_EXPORT socketio_client final : public websocket_client_interface +{ +public: + explicit socketio_client(boost::asio::io_context& ctx); + socketio_client(boost::asio::io_context& ctx, ws_client_message_handler handler); + + ~socketio_client() override; + + void connect(const std::string& uri) override; + void connect_and_run(const std::string& uri) override; + void stop() override; + bool connected() const override; + + void send_message(const std::string& request) override; + void send_message(const rapidjson::StringBuffer& request) override; + void send_binary_message(std::string_view request) override; + +private: + using websocket_type = boost::beast::websocket::stream; + template + using awaitable = boost::asio::awaitable; + + // Coroutine-based session management + awaitable run_session(std::string host, std::string port, std::string path); + + awaitable http_request( + boost::beast::http::verb v, boost::beast::tcp_stream& stream, + std::string_view target, std::string_view body = ""); + + awaitable read_loop(); + awaitable write_loop(); + awaitable write_pong(); + awaitable close_session(); + + bool process_engineio(std::string_view data); + void dispatch_socketio_message(std::string_view data); + + void enqueue_write(std::string msg); + + void co_spawn_detached(auto&& f) + { + boost::asio::co_spawn( + m_context, std::move(f), + [](std::exception_ptr e) { + if(e) + { + try { std::rethrow_exception(e); } + catch(const std::exception& ex) { + ossia::logger().error("socketio_client error: {}", ex.what()); + } + catch(...) { + ossia::logger().error("socketio_client: unknown error"); + } + } + }); + } + + boost::asio::io_context& m_context; + std::unique_ptr m_ws; + boost::beast::flat_buffer m_read_buffer; + boost::asio::experimental::channel + m_write_channel; + + engineio_config m_config; + ws_client_message_handler m_on_message; + std::string m_host; + std::mutex m_mutex; + std::atomic_bool m_open{false}; + std::atomic_bool m_connected{false}; +}; + +} diff --git a/src/ossia/protocols/socketio/socketio_server.cpp b/src/ossia/protocols/socketio/socketio_server.cpp new file mode 100644 index 00000000000..36f062d5c18 --- /dev/null +++ b/src/ossia/protocols/socketio/socketio_server.cpp @@ -0,0 +1,739 @@ +// Socket.IO uses Boost.Beast TCP acceptors / coroutines which are not +// available in the Emscripten/browser sandbox. +#if !defined(__EMSCRIPTEN__) +#include + +#include + +#include +#include + +#include + +namespace ossia::net +{ + +// ---- SID generation ---- + +static std::string generate_random_sid() +{ + // FIXME use pcg + static thread_local std::mt19937 gen{std::random_device{}()}; + static constexpr char chars[] + = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + std::uniform_int_distribution dist(0, sizeof(chars) - 2); + std::string sid; + sid.reserve(20); + for(int i = 0; i < 20; ++i) + sid += chars[dist(gen)]; + return sid; +} + +// ---- socketio_server_connection ---- + +socketio_server_connection::socketio_server_connection( + boost::asio::ip::tcp::socket&& socket, socketio_server& server) + : m_ws{std::move(socket)} + , m_ping_timer{m_ws.get_executor()} + , m_server{server} +{ + auto& tcp = boost::beast::get_lowest_layer(m_ws); + tcp.expires_never(); + boost::asio::ip::tcp::no_delay opt(true); + try + { + tcp.socket().set_option(opt); + } + catch(...) + { + } + + m_config.sid = generate_random_sid(); +} + +socketio_server_connection::~socketio_server_connection() = default; + +void socketio_server_connection::run() +{ + do_read_http(); +} + +void socketio_server_connection::send_text(const std::string& msg) +{ + enqueue_write(true, msg); +} + +void socketio_server_connection::send_text(const char* data, std::size_t sz) +{ + enqueue_write(true, std::string(data, sz)); +} + +void socketio_server_connection::send_binary(std::string_view msg) +{ + enqueue_write(false, std::string(msg)); +} + +void socketio_server_connection::enqueue_write(bool text, std::string data) +{ + // Sends originate on the producer thread. A beast websocket::stream is not + // thread-safe, so marshal every send onto the connection strand: the poll + // buffer and the write queue then become strand-confined, and only one + // async_write is ever in flight. + boost::asio::dispatch( + m_ws.get_executor(), + [self = shared_from_this(), text, data = std::move(data)]() mutable { + if(!self->m_is_websocket) + { + self->m_poll_buffer.push_back(std::move(data)); + return; + } + const bool idle = self->m_write_queue.empty(); + self->m_write_queue.emplace_back(text, std::move(data)); + if(idle) + self->do_write(); + }); +} + +void socketio_server_connection::do_write() +{ + m_ws.text(m_write_queue.front().first); + m_ws.async_write( + boost::asio::buffer(m_write_queue.front().second), + [self = shared_from_this()](boost::beast::error_code ec, std::size_t bytes) { + self->on_write(ec, bytes); + }); +} + +void socketio_server_connection::on_write(boost::beast::error_code ec, std::size_t) +{ + if(ec) + { + m_write_queue.clear(); + return; + } + m_write_queue.pop_front(); + if(!m_write_queue.empty()) + do_write(); +} + +void socketio_server_connection::close() +{ + // Run the close on the strand: the timer and stream are shared with the + // in-flight async_read/async_wait on the io thread. + boost::asio::dispatch(m_ws.get_executor(), [self = shared_from_this()] { + self->m_ping_timer.cancel(); + boost::beast::error_code ec; + auto& tcp = boost::beast::get_lowest_layer(self->m_ws); + tcp.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + tcp.socket().close(ec); + }); +} + +boost::asio::ip::tcp::socket& socketio_server_connection::tcp_socket() +{ + return boost::beast::get_lowest_layer(m_ws).socket(); +} + +std::string socketio_server_connection::generate_sid() +{ + return generate_random_sid(); +} + +void socketio_server_connection::do_read_http() +{ + m_buffer.clear(); + m_http_req = {}; + + boost::beast::http::async_read( + boost::beast::get_lowest_layer(m_ws), m_buffer, m_http_req, + [self = shared_from_this()](boost::beast::error_code ec, std::size_t bytes) { + self->on_read_http(ec, bytes); + }); +} + +void socketio_server_connection::on_read_http(boost::beast::error_code ec, std::size_t) +{ + if(ec) + return; + + auto target = std::string(m_http_req.target()); + + // Check for WebSocket upgrade + if(boost::beast::websocket::is_upgrade(m_http_req)) + { + m_is_websocket = true; + do_accept_ws(); + return; + } + + // Engine.IO HTTP polling + if(target.find("transport=polling") != std::string::npos) + { + if(target.find("sid=") == std::string::npos) + { + // New connection: no SID yet + handle_new_connection(m_http_req); + } + else if(m_http_req.method() == boost::beast::http::verb::get) + { + handle_polling_get(m_http_req); + } + else if(m_http_req.method() == boost::beast::http::verb::post) + { + handle_polling_post(m_http_req); + } + // Continue reading next HTTP request + do_read_http(); + return; + } + + // Unknown request + send_http_response( + m_http_req.version(), boost::beast::http::status::not_found, "Not found"); + do_read_http(); +} + +void socketio_server_connection::handle_new_connection( + boost::beast::http::request& req) +{ + // Send Engine.IO OPEN packet + auto open_packet = make_engineio_open(m_config); + + send_http_response( + req.version(), boost::beast::http::status::ok, open_packet, + "text/plain; charset=UTF-8"); + m_handshake_done = true; +} + +void socketio_server_connection::handle_polling_get( + boost::beast::http::request& req) +{ + if(!m_poll_buffer.empty()) + { + // Concatenate buffered messages with record separator + std::string response; + for(size_t i = 0; i < m_poll_buffer.size(); ++i) + { + if(i > 0) + response += '\x1e'; + response += m_poll_buffer[i]; + } + m_poll_buffer.clear(); + send_http_response( + req.version(), boost::beast::http::status::ok, response, + "text/plain; charset=UTF-8"); + } + else + { + // Send NOOP to keep the connection alive + send_http_response( + req.version(), boost::beast::http::status::ok, + std::string(1, EIO_NOOP), "text/plain; charset=UTF-8"); + } +} + +void socketio_server_connection::handle_polling_post( + boost::beast::http::request& req) +{ + auto body = req.body(); + + // Parse potentially multiple messages separated by \x1e + std::string_view sv = body; + while(!sv.empty()) + { + auto sep = sv.find('\x1e'); + auto msg = (sep != std::string_view::npos) ? sv.substr(0, sep) : sv; + + process_engineio_message(msg); + + if(sep != std::string_view::npos) + sv = sv.substr(sep + 1); + else + break; + } + + send_http_response(req.version(), boost::beast::http::status::ok, "ok"); +} + +void socketio_server_connection::send_http_response( + unsigned version, boost::beast::http::status status, const std::string& body, + const std::string& content_type) +{ + namespace http = boost::beast::http; + http::response res{status, version}; + res.set(http::field::server, "ossia"); + res.set(http::field::access_control_allow_origin, "*"); + res.set(http::field::content_type, content_type); + res.set(http::field::connection, "keep-alive"); + res.body() = body; + res.prepare_payload(); + + boost::beast::error_code ec; + http::write(boost::beast::get_lowest_layer(m_ws), res, ec); +} + +void socketio_server_connection::do_accept_ws() +{ + m_ws.set_option(boost::beast::websocket::stream_base::timeout::suggested( + boost::beast::role_type::server)); + + // async_accept does not take ownership of the request; it holds a reference + // for the duration of the operation. Pass the member m_http_req (which lives + // as long as this connection via the captured shared_from_this), never a + // local/temporary, otherwise the borrowed request is destroyed before the + // handshake completes. + m_ws.async_accept( + m_http_req, + [self = shared_from_this()](boost::beast::error_code ec) { + self->on_accept_ws(ec); + }); +} + +void socketio_server_connection::on_accept_ws(boost::beast::error_code ec) +{ + if(ec) + return; + + m_is_websocket = true; + // Don't notify server yet: wait for the probe/upgrade sequence + do_read_ws(); +} + +void socketio_server_connection::do_read_ws() +{ + m_buffer.clear(); + m_ws.async_read( + m_buffer, + [self = shared_from_this()](boost::beast::error_code ec, std::size_t bytes) { + self->on_read_ws(ec, bytes); + }); +} + +void socketio_server_connection::on_read_ws(boost::beast::error_code ec, std::size_t) +{ + if(ec) + { + m_ping_timer.cancel(); + m_server.remove_connection(shared_from_this()); + return; + } + + auto data = boost::beast::buffers_to_string(m_buffer.data()); + + // Handle probe sequence + if(data == "2probe") + { + // Respond with pong probe through the write queue so it cannot overlap + // another in-flight async_write on the same stream. + enqueue_write(true, "3probe"); + do_read_ws(); + return; + } + + if(data == "5") + { + // Now the client is fully on WebSocket + m_upgraded = true; + + // Notify the server that this connection is ready + m_server.add_connection(shared_from_this()); + + // Start ping timer + start_ping_timer(); + + do_read_ws(); + return; + } + + // Normal Engine.IO message + process_engineio_message(data); + do_read_ws(); +} + +void socketio_server_connection::start_ping_timer() +{ + m_ping_timer.expires_after(m_config.ping_interval); + m_ping_timer.async_wait( + [self = shared_from_this()](boost::beast::error_code ec) { + self->on_ping_timer(ec); + }); +} + +void socketio_server_connection::on_ping_timer(boost::beast::error_code ec) +{ + if(ec) + return; // Timer cancelled + + // Send ping through the write queue (never overlap another async_write). + if(m_is_websocket && m_ws.is_open()) + { + enqueue_write(true, "2"); + start_ping_timer(); + } +} + +void socketio_server_connection::process_engineio_message(std::string_view data) +{ + if(data.empty()) + return; + + switch(data[0]) + { + case EIO_PONG: + // Client responded to our ping: connection alive + break; + case EIO_CLOSE: + m_ping_timer.cancel(); + m_server.remove_connection(shared_from_this()); + break; + case EIO_MESSAGE: + data = data.substr(1); + dispatch_socketio_message(data); + break; + case EIO_PING: + // Client shouldn't send pings in v4, but respond anyway + send_text(std::string(1, EIO_PONG)); + break; + default: + break; + } +} + +void socketio_server_connection::dispatch_socketio_message(std::string_view data) +{ + if(data.empty()) + return; + + switch(data[0]) + { + case SIO_CONNECT: { + // Client sends CONNECT. Anwser: CONNECT + sid + auto response = make_socketio_connect(m_config.sid); + send_text(response); + break; + } + case SIO_DISCONNECT: + m_ping_timer.cancel(); + m_server.remove_connection(shared_from_this()); + break; + case SIO_EVENT: { + data = data.substr(1); + if(!data.empty() && m_server.m_on_message) + { + auto payload = std::string(data); + try + { + auto reply = m_server.m_on_message( + shared_from_this(), ws_opcode::text, payload); + if(!reply.data.empty()) + { + // Wrap reply as Socket.IO EVENT + std::string response; + response.reserve(2 + reply.data.size()); + response += EIO_MESSAGE; + response += SIO_EVENT; + response += reply.data; + send_text(response); + } + } + catch(const std::exception& e) + { + ossia::logger().error("socketio_server: message error: {}", e.what()); + } + } + break; + } + case SIO_BINARY_EVENT: { + data = data.substr(1); + // Skip attachment count + if(auto dash = data.find('-'); dash != std::string_view::npos) + data = data.substr(dash + 1); + if(!data.empty() && m_server.m_on_message) + { + auto payload = std::string(data); + try + { + auto reply = m_server.m_on_message( + shared_from_this(), ws_opcode::binary, payload); + if(!reply.data.empty()) + { + std::string response; + response.reserve(2 + reply.data.size()); + response += EIO_MESSAGE; + response += SIO_BINARY_EVENT; + response += reply.data; + send_text(response); + } + } + catch(const std::exception& e) + { + ossia::logger().error("socketio_server: message error: {}", e.what()); + } + } + break; + } + default: + break; + } +} + +// ---- socketio_server ---- + +socketio_server::socketio_server(ossia::net::network_context_ptr ctx) + : m_context{std::move(ctx)} + , m_acceptor{boost::asio::make_strand(m_context->context)} +{ +} + +socketio_server::~socketio_server() +{ + stop(); +} + +void socketio_server::listen(uint16_t port) +{ + // Dual-stack: try v6 first, fall back to v4 + boost::asio::ip::tcp::endpoint endpoint{boost::asio::ip::tcp::v6(), port}; + + boost::beast::error_code ec; + m_acceptor.open(endpoint.protocol(), ec); + if(ec) + return; + + m_acceptor.set_option(boost::asio::socket_base::reuse_address(true), ec); + m_acceptor.set_option(boost::asio::ip::v6_only(false), ec); + + m_acceptor.bind(endpoint, ec); + if(ec) + { + m_acceptor.close(ec); + endpoint = boost::asio::ip::tcp::endpoint{boost::asio::ip::tcp::v4(), port}; + m_acceptor.open(endpoint.protocol(), ec); + if(ec) + return; + m_acceptor.set_option(boost::asio::socket_base::reuse_address(true), ec); + m_acceptor.bind(endpoint, ec); + if(ec) + return; + } + + m_acceptor.listen(boost::asio::socket_base::max_listen_connections, ec); + if(ec) + return; + + do_accept(); +} + +void socketio_server::run() +{ + m_context->context.run(); +} + +void socketio_server::stop() +{ + boost::beast::error_code ec; + if(m_acceptor.is_open()) + m_acceptor.close(ec); + + std::lock_guard lock{m_connections_mutex}; + for(auto& conn : m_connections) + conn->close(); + m_connections.clear(); +} + +void socketio_server::close(ws_connection_handle hdl) +{ + if(auto conn = find_connection(hdl)) + conn->close(); +} + +void socketio_server::set_open_handler(ws_open_handler h) +{ + m_on_open = std::move(h); +} + +void socketio_server::set_close_handler(ws_close_handler h) +{ + m_on_close = std::move(h); +} + +void socketio_server::set_message_handler(ws_server_message_handler h) +{ + m_on_message = std::move(h); +} + +std::string socketio_server::get_remote_ip(const ws_connection_handle& hdl) +{ + if(auto conn = find_connection(hdl)) + { + try + { + return conn->tcp_socket().remote_endpoint().address().to_string(); + } + catch(...) + { + } + } + return {}; +} + +std::string socketio_server::get_remote_endpoint(const ws_connection_handle& hdl) +{ + if(auto conn = find_connection(hdl)) + { + try + { + auto ep = conn->tcp_socket().remote_endpoint(); + return ep.address().to_string() + ":" + std::to_string(ep.port()); + } + catch(...) + { + } + } + return {}; +} + +std::string socketio_server::get_local_ip(const ws_connection_handle& hdl) +{ + if(auto conn = find_connection(hdl)) + { + try + { + return conn->tcp_socket().local_endpoint().address().to_string(); + } + catch(...) + { + } + } + return {}; +} + +void socketio_server::send_message( + ws_connection_handle hdl, const std::string& message) +{ + if(auto conn = find_connection(hdl)) + send_sio_text(conn, message); +} + +void socketio_server::send_message( + ws_connection_handle hdl, const server_reply& message) +{ + if(auto conn = find_connection(hdl)) + { + switch(message.type) + { + case server_reply::data_type::json: + case server_reply::data_type::html: + send_sio_text(conn, message.data); + break; + default: + send_sio_binary(conn, message.data); + break; + } + } +} + +void socketio_server::send_message( + ws_connection_handle hdl, const rapidjson::StringBuffer& message) +{ + if(auto conn = find_connection(hdl)) + send_sio_text(conn, std::string(message.GetString(), message.GetSize())); +} + +void socketio_server::send_binary_message( + ws_connection_handle hdl, std::string_view message) +{ + if(auto conn = find_connection(hdl)) + send_sio_binary(conn, message); +} + +void socketio_server::send_sio_text( + socketio_server_connection* conn, const std::string& msg) +{ + // Wrap as EIO_MESSAGE + SIO_EVENT + payload + std::string packet; + packet.reserve(2 + msg.size()); + packet += EIO_MESSAGE; + packet += SIO_EVENT; + packet += msg; + conn->send_text(packet); +} + +void socketio_server::send_sio_binary( + socketio_server_connection* conn, std::string_view msg) +{ + // Wrap as EIO_MESSAGE + SIO_BINARY_EVENT + payload + std::string packet; + packet.reserve(2 + msg.size()); + packet += EIO_MESSAGE; + packet += SIO_BINARY_EVENT; + packet += msg; + conn->send_binary(packet); +} + +void socketio_server::do_accept() +{ + m_acceptor.async_accept( + boost::asio::make_strand(m_context->context), + [this](boost::beast::error_code ec, boost::asio::ip::tcp::socket socket) { + on_accept(ec, std::move(socket)); + }); +} + +void socketio_server::on_accept( + boost::beast::error_code ec, boost::asio::ip::tcp::socket socket) +{ + if(ec) + return; + + auto conn + = std::make_shared(std::move(socket), *this); + conn->run(); + + do_accept(); +} + +void socketio_server::add_connection( + std::shared_ptr conn) +{ + ws_connection_handle hdl = conn; + { + std::lock_guard lock{m_connections_mutex}; + m_connections.insert(std::move(conn)); + } + + if(m_on_open) + m_on_open(hdl); +} + +void socketio_server::remove_connection( + std::shared_ptr conn) +{ + ws_connection_handle hdl = conn; + + { + std::lock_guard lock{m_connections_mutex}; + m_connections.erase(conn); + } + + if(m_on_close) + m_on_close(hdl); +} + +socketio_server_connection* +socketio_server::find_connection(const ws_connection_handle& hdl) +{ + auto sp = hdl.lock(); + if(!sp) + return nullptr; + + std::lock_guard lock{m_connections_mutex}; + auto it = m_connections.find( + std::static_pointer_cast(sp)); + if(it != m_connections.end()) + return it->get(); + return nullptr; +} + +} +#endif diff --git a/src/ossia/protocols/socketio/socketio_server.hpp b/src/ossia/protocols/socketio/socketio_server.hpp new file mode 100644 index 00000000000..19843e7b09d --- /dev/null +++ b/src/ossia/protocols/socketio/socketio_server.hpp @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ossia::net +{ + +class socketio_server; + +/// A single client connection managed by the Socket.IO server. +/// Handles the Engine.IO handshake, polling transport, WebSocket upgrade, +/// and Socket.IO packet framing. +class socketio_server_connection + : public std::enable_shared_from_this +{ + friend class socketio_server; + +public: + socketio_server_connection( + boost::asio::ip::tcp::socket&& socket, socketio_server& server); + ~socketio_server_connection(); + + void run(); + void send_text(const std::string& msg); + void send_text(const char* data, std::size_t sz); + void send_binary(std::string_view msg); + void close(); + + boost::asio::ip::tcp::socket& tcp_socket(); + const std::string& sid() const { return m_config.sid; } + +private: + void do_read_http(); + void on_read_http(boost::beast::error_code ec, std::size_t bytes); + + // HTTP polling handlers + void handle_polling_get( + boost::beast::http::request& req); + void handle_polling_post( + boost::beast::http::request& req); + void handle_new_connection( + boost::beast::http::request& req); + void send_http_response( + unsigned version, boost::beast::http::status status, + const std::string& body, const std::string& content_type = "text/plain"); + + // WebSocket mode + void do_accept_ws(); + void on_accept_ws(boost::beast::error_code ec); + void do_read_ws(); + void on_read_ws(boost::beast::error_code ec, std::size_t bytes); + + // Ping timer + void start_ping_timer(); + void on_ping_timer(boost::beast::error_code ec); + + // Outbound writes, serialized on the strand (see beast server). + void enqueue_write(bool text, std::string data); + void do_write(); + void on_write(boost::beast::error_code ec, std::size_t bytes); + + // Engine.IO / Socket.IO processing + void process_engineio_message(std::string_view data); + void dispatch_socketio_message(std::string_view data); + + static std::string generate_sid(); + + boost::beast::websocket::stream m_ws; + boost::beast::flat_buffer m_buffer; + boost::beast::http::request m_http_req; + boost::asio::steady_timer m_ping_timer; + socketio_server& m_server; + engineio_config m_config; + + // Buffered messages for long-polling GET + std::vector m_poll_buffer; + + // Outbound WebSocket queue, only touched on the strand. {is_text, payload}. + std::deque> m_write_queue; + + bool m_is_websocket{false}; + bool m_handshake_done{false}; + bool m_upgraded{false}; +}; + +/// Socket.IO server implementing websocket_server_interface. +/// +/// Handles Engine.IO v4 handshake (HTTP polling + WebSocket upgrade), +/// Socket.IO v5 CONNECT, ping/pong, and message framing. +class OSSIA_EXPORT socketio_server final : public websocket_server_interface +{ + friend class socketio_server_connection; + +public: + explicit socketio_server(ossia::net::network_context_ptr ctx); + ~socketio_server() override; + + void listen(uint16_t port) override; + void run() override; + void stop() override; + void close(ws_connection_handle hdl) override; + + void set_open_handler(ws_open_handler h) override; + void set_close_handler(ws_close_handler h) override; + void set_message_handler(ws_server_message_handler h) override; + + std::string get_remote_ip(const ws_connection_handle& hdl) override; + std::string get_remote_endpoint(const ws_connection_handle& hdl) override; + std::string get_local_ip(const ws_connection_handle& hdl) override; + + void send_message(ws_connection_handle hdl, const std::string& message) override; + void send_message(ws_connection_handle hdl, const server_reply& message) override; + void + send_message(ws_connection_handle hdl, const rapidjson::StringBuffer& message) override; + void send_binary_message(ws_connection_handle hdl, std::string_view message) override; + +private: + void do_accept(); + void on_accept(boost::beast::error_code ec, boost::asio::ip::tcp::socket socket); + void add_connection(std::shared_ptr conn); + void remove_connection(std::shared_ptr conn); + socketio_server_connection* find_connection(const ws_connection_handle& hdl); + + /// Wrap a text message as Socket.IO EVENT and send + void send_sio_text(socketio_server_connection* conn, const std::string& msg); + /// Wrap binary data as Socket.IO BINARY_EVENT and send + void send_sio_binary(socketio_server_connection* conn, std::string_view msg); + + ossia::net::network_context_ptr m_context; + boost::asio::ip::tcp::acceptor m_acceptor; + std::set> m_connections; + std::mutex m_connections_mutex; + + ws_open_handler m_on_open; + ws_close_handler m_on_close; + ws_server_message_handler m_on_message; +}; + +} diff --git a/src/ossia/protocols/socketio/socketio_session.hpp b/src/ossia/protocols/socketio/socketio_session.hpp new file mode 100644 index 00000000000..8c679b16458 --- /dev/null +++ b/src/ossia/protocols/socketio/socketio_session.hpp @@ -0,0 +1,161 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ossia::net +{ + +/// Engine.IO v4 packet types +enum engine_io_packet_type : char +{ + EIO_OPEN = '0', + EIO_CLOSE = '1', + EIO_PING = '2', + EIO_PONG = '3', + EIO_MESSAGE = '4', + EIO_UPGRADE = '5', + EIO_NOOP = '6', +}; + +/// Socket.IO v5 packet types (carried inside EIO_MESSAGE) +enum socket_io_packet_type : char +{ + SIO_CONNECT = '0', + SIO_DISCONNECT = '1', + SIO_EVENT = '2', + SIO_ACK = '3', + SIO_CONNECT_ERROR = '4', + SIO_BINARY_EVENT = '5', + SIO_BINARY_ACK = '6', +}; + +/// Engine.IO configuration received from the server handshake +struct engineio_config +{ + std::string sid; + std::string socketio_sid; + std::chrono::milliseconds ping_interval{25000}; + std::chrono::milliseconds ping_timeout{20000}; + int64_t max_payload{1000000}; + bool can_websocket{false}; +}; + +/// Parses Engine.IO OPEN packet JSON +inline bool parse_engineio_open(std::string_view str, engineio_config& config) +{ + if(str.size() < 2 || !str.starts_with("0{")) + return false; + + str = str.substr(1); + boost::system::error_code ec; + auto json = boost::json::parse(str, ec); + if(ec) + return false; + if(auto obj = json.try_as_object()) + { + auto& o = *obj; + if(auto k = o.find("sid")) + if(auto sid = k->value().try_as_string()) + config.sid = *sid; + + if(auto k = o.find("upgrades")) + if(auto v = k->value().try_as_array()) + if(v->size() > 0 && v->front() == "websocket") + config.can_websocket = true; + + if(auto k = o.find("pingInterval")) + if(auto v = k->value().try_as_int64()) + config.ping_interval = std::chrono::milliseconds(*v); + + if(auto k = o.find("pingTimeout")) + if(auto v = k->value().try_as_int64()) + config.ping_timeout = std::chrono::milliseconds(*v); + + if(auto k = o.find("maxPayload")) + if(auto v = k->value().try_as_int64()) + config.max_payload = *v; + } + return !config.sid.empty(); +} + +/// Parses Socket.IO CONNECT response (e.g. "40{"sid":"..."}") +inline bool parse_socketio_connect(std::string_view str, engineio_config& config) +{ + if(str.size() < 4 || !str.starts_with("40{")) + return false; + if(auto end = str.find('\x1e'); end != std::string_view::npos) + str = str.substr(0, end); + + str = str.substr(2); + boost::system::error_code ec; + auto json = boost::json::parse(str, ec); + if(ec) + return false; + if(auto obj = json.try_as_object()) + { + if(auto k = obj->find("sid")) + if(auto sid = k->value().try_as_string()) + { + config.socketio_sid = *sid; + return true; + } + } + return false; +} + +/// Generates Engine.IO OPEN packet JSON +inline std::string make_engineio_open(const engineio_config& config) +{ + boost::json::object obj; + obj["sid"] = config.sid; + obj["upgrades"] = boost::json::array{"websocket"}; + obj["pingInterval"] = config.ping_interval.count(); + obj["pingTimeout"] = config.ping_timeout.count(); + obj["maxPayload"] = config.max_payload; + return std::string(1, EIO_OPEN) + boost::json::serialize(obj); +} + +/// Generates Socket.IO CONNECT response +inline std::string make_socketio_connect(const std::string& sid) +{ + boost::json::object obj; + obj["sid"] = sid; + return std::string("40") + boost::json::serialize(obj); +} + +/// Helper to consume a leading integer from a string_view +inline std::optional consume_int(std::string_view& input) +{ + int out; + const std::from_chars_result result + = std::from_chars(input.data(), input.data() + input.size(), out); + if(result.ec == std::errc::invalid_argument + || result.ec == std::errc::result_out_of_range) + return std::nullopt; + + int n = result.ptr - input.data(); + input = input.substr(n); + return out; +} + +} diff --git a/src/ossia_features.cmake b/src/ossia_features.cmake index 66073f6c5ba..1faec7973d0 100644 --- a/src/ossia_features.cmake +++ b/src/ossia_features.cmake @@ -86,7 +86,15 @@ endif() if(OSSIA_PROTOCOL_OSCQUERY) target_sources(ossia PRIVATE ${OSSIA_OSCQUERY_SRCS} ${OSSIA_OSCQUERY_HEADERS}) target_link_libraries(ossia PUBLIC $) - target_link_libraries(ossia PRIVATE $) + set_source_files_properties( + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/boost_json_impl.cpp" + PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) + if(EMSCRIPTEN) + # Use the browser's native WebSocket API for the OSCQuery client. + # The server side and the OSCQuery HTTP fallback are not available + # in the browser sandbox. + target_link_options(ossia PUBLIC "-lwebsocket.js") + endif() set(OSSIA_PROTOCOLS ${OSSIA_PROTOCOLS} OSCQuery) endif() @@ -247,8 +255,6 @@ if(OSSIA_DATAFLOW) target_link_libraries(ossia PUBLIC $ - PRIVATE - $ ) # JACK support diff --git a/src/ossia_install.cmake b/src/ossia_install.cmake index 473ca485cd1..7279f189fad 100644 --- a/src/ossia_install.cmake +++ b/src/ossia_install.cmake @@ -206,12 +206,6 @@ install(DIRECTORY "${OSSIA_3RDPARTY_FOLDER}/fmt/include/fmt" ${3RDPARTY_INSTALL_PATTERN} ) -install(DIRECTORY "${OSSIA_3RDPARTY_FOLDER}/websocketpp/websocketpp" - DESTINATION include - COMPONENT Devel - MESSAGE_NEVER - ${3RDPARTY_INSTALL_PATTERN} -) install(FILES "${OSSIA_3RDPARTY_FOLDER}/SmallFunction/smallfun/include/smallfun.hpp" DESTINATION include/ diff --git a/src/ossia_setup.cmake b/src/ossia_setup.cmake index 2908bd6fac6..b6e4da2497c 100644 --- a/src/ossia_setup.cmake +++ b/src/ossia_setup.cmake @@ -11,6 +11,9 @@ target_compile_definitions(ossia RAPIDJSON_HAS_STDSTRING=1 TINYSPLINE_DOUBLE_PRECISION BOOST_NO_RTTI=1 + # Never let Boost emit MSVC auto-link `#pragma comment(lib, ...)` directives: + # we use Boost entirely header-only (json via src.hpp, container/etc. inline). + BOOST_ALL_NO_LIB=1 BOOST_MATH_DISABLE_FLOAT128=1 $<$:BOOST_MULTI_INDEX_ENABLE_INVARIANT_CHECKING> $<$:BOOST_MULTI_INDEX_ENABLE_SAFE_MODE> @@ -169,7 +172,6 @@ target_link_libraries(ossia $ $ $ - $ $ $ $ diff --git a/src/ossia_sources.cmake b/src/ossia_sources.cmake index 6c9be720bfc..4b619f22395 100644 --- a/src/ossia_sources.cmake +++ b/src/ossia_sources.cmake @@ -9,6 +9,7 @@ set(API_HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/apply.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/apply_type.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/audio_spin_mutex.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/yield.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/buffer_pool.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/callback_container.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/detail/case_insensitive.hpp" @@ -224,9 +225,16 @@ set(API_HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/unix_socket.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/var_size_prefix_framing.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket.hpp" - "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client_beast.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client_emscripten.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client_interface.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_common.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_factory.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_header_client.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_reply.hpp" - "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_server.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_server_beast.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_server_interface.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_simple_beast.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/writers.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/value/destination.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/value/detail/value_conversion_impl.hpp" @@ -513,9 +521,20 @@ set(OSSIA_OSCQUERY_HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/dense/dense_protocol.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/dense/dense_protocol_configuration.hpp" + + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/socketio_session.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/socketio_client.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/socketio_server.hpp" ) set(OSSIA_OSCQUERY_SRCS + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client_beast.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client_emscripten.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_client_interface.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_factory.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_server_beast.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/sockets/websocket_server_interface.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/oscquery/oscquery_server.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/network/oscquery/oscquery_mirror.cpp" @@ -533,6 +552,10 @@ set(OSSIA_OSCQUERY_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/oscquery/oscquery_server_asio.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/dense/dense_protocol.cpp" + + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/socketio_client.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/socketio_server.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/socketio/boost_json_impl.cpp" ) set(OSSIA_HTTP_HEADERS diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e0d41c7d18e..00c8315cc75 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -143,6 +143,7 @@ if(OSSIA_PROTOCOL_OSCQUERY) ossia_add_test(OSCQueryValueCallbackTest "${CMAKE_CURRENT_SOURCE_DIR}/Network/OSCQueryValueCallbackTest.cpp") endif() ossia_add_test(MultiplexTest "${CMAKE_CURRENT_SOURCE_DIR}/Network/MultiplexTest.cpp") + ossia_add_test(NetworkThreadSafetyTest "${CMAKE_CURRENT_SOURCE_DIR}/Network/NetworkThreadSafetyTest.cpp") ossia_add_test(OSCQueryRename "Network/OSCQuery_rename.cpp") endif() diff --git a/tests/Network/HttpClientRequestTest.cpp b/tests/Network/HttpClientRequestTest.cpp index 1d7a02176fd..b430b8be280 100644 --- a/tests/Network/HttpClientRequestTest.cpp +++ b/tests/Network/HttpClientRequestTest.cpp @@ -2,6 +2,14 @@ #include #include +#include +#include +#include +#include +#include +#include +#include + #include "include_catch.hpp" #include diff --git a/tests/Network/NetworkThreadSafetyTest.cpp b/tests/Network/NetworkThreadSafetyTest.cpp new file mode 100644 index 00000000000..6ba9e9a7dba --- /dev/null +++ b/tests/Network/NetworkThreadSafetyTest.cpp @@ -0,0 +1,218 @@ +// Validation tests for the Boost.Beast network-stack review findings. +// +// These tests are designed to be run under ThreadSanitizer and +// AddressSanitizer. They exercise the exact pattern that the review +// flagged as a data race / use-after-free: +// +// * the OSCQuery server / websocket server run their io_context on a +// dedicated thread (an async_read is permanently in flight), while +// * the application thread (here: the test thread) calls send_*() which, +// in the buggy version, performed a *synchronous* beast write on the +// foreign thread — concurrent access to a beast websocket::stream, +// which is documented as not thread-safe. +// +// After the fix (sends marshalled onto the connection strand via an async +// write queue) these run clean under tsan/asan. + +#include + +#include + +#include "include_catch.hpp" + +#include +#include +#include +#include + +#if defined(OSSIA_PROTOCOL_OSCQUERY) +#include +#include + +#include +#include +#include +#include +#include + +namespace +{ +void spin_until(std::function cond, int max_ms = 4000) +{ + using namespace std::chrono; + auto deadline = steady_clock::now() + milliseconds(max_ms); + while(!cond() && steady_clock::now() < deadline) + std::this_thread::sleep_for(milliseconds(5)); +} +} + +// Direct reproduction: the producer thread calls server.send_*() while the +// server's io thread has an async_read pending on the same connection. +TEST_CASE( + "ws_beast_concurrent_send_read", "ws_beast_concurrent_send_read") +{ + using namespace ossia::net; + constexpr uint16_t port = 28765; + + auto server_ctx = std::make_shared(); + websocket_server_beast server{server_ctx}; + + std::mutex hmtx; + ws_connection_handle hdl; + std::atomic_bool have_hdl{false}; + + server.set_open_handler([&](ws_connection_handle h) { + std::lock_guard l{hmtx}; + hdl = h; + have_hdl = true; + }); + server.set_close_handler([&](ws_connection_handle) { have_hdl = false; }); + server.set_message_handler( + [](const ws_connection_handle&, ws_opcode, const std::string&) { + return server_reply{}; + }); + + server.listen(port); + std::thread server_thread([&] { server.run(); }); + + auto client_ctx = std::make_shared(); + std::atomic_int received{0}; + websocket_client_beast client{ + client_ctx->context, + [&](const ws_connection_handle&, ws_opcode, std::string&) { ++received; }}; + client.connect("ws://127.0.0.1:" + std::to_string(port)); + std::thread client_thread([&] { client_ctx->context.run(); }); + + spin_until([&] { return have_hdl.load() && client.connected(); }); + REQUIRE(have_hdl.load()); + REQUIRE(client.connected()); + + // Hammer sends from this (foreign) thread while the server io thread is + // reading on the same connection. In the buggy version this is a data + // race on the beast stream that tsan reports. + const std::string payload(64, 'x'); + for(int i = 0; i < 3000; ++i) + { + ws_connection_handle h; + { + std::lock_guard l{hmtx}; + h = hdl; + } + server.send_binary_message(h, payload); + } + + // The client must actually have received some of them (proves the writes + // really happened, not just enqueued-and-dropped). + spin_until([&] { return received.load() > 0; }); + REQUIRE(received.load() > 0); + + client.stop(); + client_ctx->context.stop(); + if(client_thread.joinable()) + client_thread.join(); + + server.stop(); + server_ctx->context.stop(); + if(server_thread.joinable()) + server_thread.join(); +} + +// Teardown reproduction: destroy the server while a connection's async_read +// is in flight on the io thread. Exercises the close()/teardown path that the +// review flagged (the macOS libc++ SIGBUS during async-handler teardown). +TEST_CASE("ws_beast_teardown_under_load", "ws_beast_teardown_under_load") +{ + using namespace ossia::net; + constexpr uint16_t port = 28766; + + for(int round = 0; round < 5; ++round) + { + auto server_ctx = std::make_shared(); + auto server = std::make_unique(server_ctx); + + std::atomic_bool opened{false}; + server->set_open_handler([&](ws_connection_handle) { opened = true; }); + server->set_close_handler([&](ws_connection_handle) {}); + server->set_message_handler( + [](const ws_connection_handle&, ws_opcode, const std::string&) { + return server_reply{}; + }); + server->listen(port); + std::thread server_thread([&] { server->run(); }); + + auto client_ctx = std::make_shared(); + websocket_client_beast client{ + client_ctx->context, + [](const ws_connection_handle&, ws_opcode, std::string&) {}}; + client.connect("ws://127.0.0.1:" + std::to_string(port)); + std::thread client_thread([&] { client_ctx->context.run(); }); + + spin_until([&] { return opened.load() && client.connected(); }); + + // Tear everything down while reads are in flight. + client.stop(); + client_ctx->context.stop(); + if(client_thread.joinable()) + client_thread.join(); + + server->stop(); + server.reset(); // destroy server while io thread may still hold connections + server_ctx->context.stop(); + if(server_thread.joinable()) + server_thread.join(); + } + REQUIRE(true); +} + +// High-level reproduction through the actual OSCQuery protocol: push values +// from the producer thread while the server services the mirror on its own +// io thread. +TEST_CASE("oscquery_concurrent_push", "oscquery_concurrent_push") +{ + using namespace ossia; + constexpr uint16_t osc_port = 28767; + constexpr uint16_t ws_port = 28768; + + net::generic_device server_dev{ + std::make_unique(osc_port, ws_port), + "srv"}; + + auto root = server_dev.create_child("val"); + auto param = root->create_parameter(ossia::val_type::INT); + param->push_value(0); + + net::generic_device mirror_dev{ + std::make_unique( + "ws://127.0.0.1:" + std::to_string(ws_port)), + "mirror"}; + + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + // Make the mirror observe the parameter so the server actually sends on + // value changes (driving server-side writes from the producer thread). + if(auto n = net::find_node(mirror_dev, "/val")) + if(auto p = n->get_parameter()) + mirror_dev.get_protocol().observe(*p, true); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Phase 1: non-critical parameter — server pushes go over the OSC-over-UDP + // path (client.sender). This exercises the m_clientsMutex-guarded sender + // setup/use against the producer thread. + param->set_critical(false); + for(int i = 0; i < 3000; ++i) + param->push_value(i); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Phase 2: critical parameter — server pushes are routed over the (beast) + // WebSocket transport instead, exercising the websocket send path the review + // flagged. + param->set_critical(true); + for(int i = 0; i < 3000; ++i) + param->push_value(i); + + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + REQUIRE(true); +} +#endif diff --git a/tests/Network/WSLogger.cpp b/tests/Network/WSLogger.cpp index 22a737fa952..568b9fdc39a 100644 --- a/tests/Network/WSLogger.cpp +++ b/tests/Network/WSLogger.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include #include "include_catch.hpp" using namespace ossia; @@ -31,7 +31,7 @@ TEST_CASE("test_websockets_log_connection", "test_websockets_log_connection") bool message = false; bool closed = false; auto ctx = std::make_shared(); - ossia::net::websocket_server srv{ctx}; + ossia::net::websocket_server_beast srv{ctx}; srv.set_open_handler([&](auto&&...) { opened = true; }); srv.set_message_handler([&](auto&&...) { message = true;