| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| #include "perfetto/ext/base/http/http_server.h" |
| |
| #include <cinttypes> |
| |
| #include <vector> |
| |
| #include "perfetto/ext/base/base64.h" |
| #include "perfetto/ext/base/endian.h" |
| #include "perfetto/ext/base/http/sha1.h" |
| #include "perfetto/ext/base/string_utils.h" |
| #include "perfetto/ext/base/string_view.h" |
| |
| namespace perfetto { |
| namespace base { |
| |
| namespace { |
| constexpr size_t kMaxPayloadSize = 64 * 1024 * 1024; |
| constexpr size_t kMaxRequestSize = kMaxPayloadSize + 4096; |
| |
| enum WebsocketOpcode : uint8_t { |
| kOpcodeContinuation = 0x0, |
| kOpcodeText = 0x1, |
| kOpcodeBinary = 0x2, |
| kOpcodeDataUnused = 0x3, |
| kOpcodeClose = 0x8, |
| kOpcodePing = 0x9, |
| kOpcodePong = 0xA, |
| kOpcodeControlUnused = 0xB, |
| }; |
| |
| // From https://datatracker.ietf.org/doc/html/rfc6455#section-1.3. |
| constexpr char kWebsocketGuid[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; |
| |
| } // namespace |
| |
| HttpServer::HttpServer(TaskRunner* task_runner, HttpRequestHandler* req_handler) |
| : task_runner_(task_runner), req_handler_(req_handler) {} |
| HttpServer::~HttpServer() = default; |
| |
| void HttpServer::Start(int port) { |
| std::string ipv4_addr = "127.0.0.1:" + std::to_string(port); |
| std::string ipv6_addr = "[::1]:" + std::to_string(port); |
| |
| sock4_ = UnixSocket::Listen(ipv4_addr, this, task_runner_, SockFamily::kInet, |
| SockType::kStream); |
| bool ipv4_listening = sock4_ && sock4_->is_listening(); |
| if (!ipv4_listening) { |
| PERFETTO_PLOG("Failed to listen on IPv4 socket"); |
| sock4_.reset(); |
| } |
| |
| sock6_ = UnixSocket::Listen(ipv6_addr, this, task_runner_, SockFamily::kInet6, |
| SockType::kStream); |
| bool ipv6_listening = sock6_ && sock6_->is_listening(); |
| if (!ipv6_listening) { |
| PERFETTO_PLOG("Failed to listen on IPv6 socket"); |
| sock6_.reset(); |
| } |
| } |
| |
| void HttpServer::AddAllowedOrigin(const std::string& origin) { |
| allowed_origins_.emplace_back(origin); |
| } |
| |
| void HttpServer::OnNewIncomingConnection( |
| UnixSocket*, // The listening socket, irrelevant here. |
| std::unique_ptr<UnixSocket> sock) { |
| PERFETTO_LOG("[HTTP] New connection"); |
| clients_.emplace_back(std::move(sock)); |
| } |
| |
| void HttpServer::OnConnect(UnixSocket*, bool) {} |
| |
| void HttpServer::OnDisconnect(UnixSocket* sock) { |
| PERFETTO_LOG("[HTTP] Client disconnected"); |
| for (auto it = clients_.begin(); it != clients_.end(); ++it) { |
| if (it->sock.get() == sock) { |
| req_handler_->OnHttpConnectionClosed(&*it); |
| clients_.erase(it); |
| return; |
| } |
| } |
| PERFETTO_DFATAL("[HTTP] Untracked client in OnDisconnect()"); |
| } |
| |
| void HttpServer::OnDataAvailable(UnixSocket* sock) { |
| HttpServerConnection* conn = nullptr; |
| for (auto it = clients_.begin(); it != clients_.end() && !conn; ++it) |
| conn = (it->sock.get() == sock) ? &*it : nullptr; |
| PERFETTO_CHECK(conn); |
| |
| char* rxbuf = reinterpret_cast<char*>(conn->rxbuf.Get()); |
| for (;;) { |
| size_t avail = conn->rxbuf_avail(); |
| PERFETTO_CHECK(avail <= kMaxRequestSize); |
| if (avail == 0) { |
| conn->SendResponseAndClose("413 Payload Too Large"); |
| return; |
| } |
| size_t rsize = sock->Receive(&rxbuf[conn->rxbuf_used], avail); |
| conn->rxbuf_used += rsize; |
| if (rsize == 0 || conn->rxbuf_avail() == 0) |
| break; |
| } |
| |
| // At this point |rxbuf| can contain a partial HTTP request, a full one or |
| // more (in case of HTTP Keepalive pipelining). |
| for (;;) { |
| size_t bytes_consumed; |
| |
| if (conn->is_websocket()) { |
| bytes_consumed = ParseOneWebsocketFrame(conn); |
| } else { |
| bytes_consumed = ParseOneHttpRequest(conn); |
| } |
| |
| if (bytes_consumed == 0) |
| break; |
| memmove(rxbuf, &rxbuf[bytes_consumed], conn->rxbuf_used - bytes_consumed); |
| conn->rxbuf_used -= bytes_consumed; |
| } |
| } |
| |
| // Parses the HTTP request and invokes HandleRequest(). It returns the size of |
| // the HTTP header + body that has been processed or 0 if there isn't enough |
| // data for a full HTTP request in the buffer. |
| size_t HttpServer::ParseOneHttpRequest(HttpServerConnection* conn) { |
| auto* rxbuf = reinterpret_cast<char*>(conn->rxbuf.Get()); |
| StringView buf_view(rxbuf, conn->rxbuf_used); |
| bool has_parsed_first_line = false; |
| bool all_headers_received = false; |
| HttpRequest http_req(conn); |
| size_t body_size = 0; |
| |
| // This loop parses the HTTP request headers and sets the |body_offset|. |
| while (!buf_view.empty()) { |
| size_t next = buf_view.find('\n'); |
| if (next == StringView::npos) |
| break; |
| StringView line = buf_view.substr(0, next); |
| buf_view = buf_view.substr(next + 1); // Eat the current line. |
| while (!line.empty() && (line.at(line.size() - 1) == '\r' || |
| line.at(line.size() - 1) == '\n')) { |
| line = line.substr(0, line.size() - 1); |
| } |
| |
| if (!has_parsed_first_line) { |
| // Parse the "GET /xxx HTTP/1.1" line. |
| has_parsed_first_line = true; |
| size_t space = line.find(' '); |
| if (space == std::string::npos || space + 2 >= line.size()) { |
| conn->SendResponseAndClose("400 Bad Request"); |
| return 0; |
| } |
| http_req.method = line.substr(0, space); |
| size_t uri_size = line.find(' ', space + 1) - (space + 1); |
| http_req.uri = line.substr(space + 1, uri_size); |
| } else if (line.empty()) { |
| all_headers_received = true; |
| // The CR-LF marker that separates headers from body. |
| break; |
| } else { |
| // Parse HTTP headers, e.g. "Content-Length: 1234". |
| size_t col = line.find(':'); |
| if (col == StringView::npos) { |
| PERFETTO_DLOG("[HTTP] Malformed HTTP header: \"%s\"", |
| line.ToStdString().c_str()); |
| conn->SendResponseAndClose("400 Bad Request", {}, "Bad HTTP header"); |
| return 0; |
| } |
| auto hdr_name = line.substr(0, col); |
| auto hdr_value = line.substr(col + 2); |
| if (http_req.num_headers < http_req.headers.size()) { |
| http_req.headers[http_req.num_headers++] = {hdr_name, hdr_value}; |
| } else { |
| conn->SendResponseAndClose("400 Bad Request", {}, |
| "Too many HTTP headers"); |
| } |
| |
| if (hdr_name.CaseInsensitiveEq("content-length")) { |
| body_size = static_cast<size_t>(atoi(hdr_value.ToStdString().c_str())); |
| } else if (hdr_name.CaseInsensitiveEq("origin")) { |
| http_req.origin = hdr_value; |
| if (IsOriginAllowed(hdr_value)) |
| conn->origin_allowed_ = hdr_value.ToStdString(); |
| } else if (hdr_name.CaseInsensitiveEq("connection")) { |
| conn->keepalive_ = hdr_value.CaseInsensitiveEq("keep-alive"); |
| http_req.is_websocket_handshake = |
| hdr_value.CaseInsensitiveEq("upgrade"); |
| } |
| } |
| } |
| |
| // At this point |buf_view| has been stripped of the header and contains the |
| // request body. We don't know yet if we have all the bytes for it or not. |
| PERFETTO_CHECK(buf_view.size() <= conn->rxbuf_used); |
| const size_t headers_size = conn->rxbuf_used - buf_view.size(); |
| |
| if (body_size + headers_size >= kMaxRequestSize || |
| body_size > kMaxPayloadSize) { |
| conn->SendResponseAndClose("413 Payload Too Large"); |
| return 0; |
| } |
| |
| // If we can't read the full request return and try again next time with more |
| // data. |
| if (!all_headers_received || buf_view.size() < body_size) |
| return 0; |
| |
| http_req.body = buf_view.substr(0, body_size); |
| |
| PERFETTO_LOG("[HTTP] %.*s %.*s [body=%zuB, origin=\"%.*s\"]", |
| static_cast<int>(http_req.method.size()), http_req.method.data(), |
| static_cast<int>(http_req.uri.size()), http_req.uri.data(), |
| http_req.body.size(), static_cast<int>(http_req.origin.size()), |
| http_req.origin.data()); |
| |
| if (http_req.method == "OPTIONS") { |
| HandleCorsPreflightRequest(http_req); |
| } else { |
| // Let the HttpHandler handle the request. |
| req_handler_->OnHttpRequest(http_req); |
| } |
| |
| // The handler is expected to send a response. If not, bail with a HTTP 500. |
| if (!conn->headers_sent_) |
| conn->SendResponseAndClose("500 Internal Server Error"); |
| |
| // Allow chaining multiple responses in the same HTTP-Keepalive connection. |
| conn->headers_sent_ = false; |
| |
| return headers_size + body_size; |
| } |
| |
| void HttpServer::HandleCorsPreflightRequest(const HttpRequest& req) { |
| req.conn->SendResponseAndClose( |
| "204 No Content", |
| { |
| "Access-Control-Allow-Methods: POST, GET, OPTIONS", // |
| "Access-Control-Allow-Headers: *", // |
| "Access-Control-Max-Age: 86400", // |
| }); |
| } |
| |
| bool HttpServer::IsOriginAllowed(StringView origin) { |
| for (const std::string& allowed_origin : allowed_origins_) { |
| if (origin.CaseInsensitiveEq(StringView(allowed_origin))) { |
| return true; |
| } |
| } |
| if (!origin_error_logged_ && !origin.empty()) { |
| origin_error_logged_ = true; |
| PERFETTO_ELOG( |
| "[HTTP] The origin \"%.*s\" is not allowed, Access-Control-Allow-Origin" |
| " won't be emitted. If this request comes from a browser it will fail.", |
| static_cast<int>(origin.size()), origin.data()); |
| } |
| return false; |
| } |
| |
| void HttpServerConnection::UpgradeToWebsocket(const HttpRequest& req) { |
| PERFETTO_CHECK(req.is_websocket_handshake); |
| |
| // |origin_allowed_| is set to the req.origin only if it's in the allowlist. |
| if (origin_allowed_.empty()) |
| return SendResponseAndClose("403 Forbidden", {}, "Origin not allowed"); |
| |
| auto ws_ver = req.GetHeader("sec-webSocket-version").value_or(StringView()); |
| auto ws_key = req.GetHeader("sec-webSocket-key").value_or(StringView()); |
| |
| if (!ws_ver.CaseInsensitiveEq("13")) |
| return SendResponseAndClose("505 HTTP Version Not Supported", {}); |
| |
| if (ws_key.size() != 24) { |
| // The nonce must be a base64 encoded 16 bytes value (24 after base64). |
| return SendResponseAndClose("400 Bad Request", {}); |
| } |
| |
| // From https://datatracker.ietf.org/doc/html/rfc6455#section-1.3 : |
| // For this header field, the server has to take the value (as present |
| // in the header field, e.g., the base64-encoded [RFC4648] version minus |
| // any leading and trailing whitespace) and concatenate this with the |
| // Globally Unique Identifier (GUID, [RFC4122]) "258EAFA5-E914-47DA- |
| // 95CA-C5AB0DC85B11" in string form, which is unlikely to be used by |
| // network endpoints that do not understand the WebSocket Protocol. A |
| // SHA-1 hash (160 bits) [FIPS.180-3], base64-encoded (see Section 4 of |
| // [RFC4648]), of this concatenation is then returned in the server's |
| // handshake. |
| StackString<128> signed_nonce("%.*s%s", static_cast<int>(ws_key.size()), |
| ws_key.data(), kWebsocketGuid); |
| auto digest = SHA1Hash(signed_nonce.c_str(), signed_nonce.len()); |
| std::string digest_b64 = Base64Encode(digest.data(), digest.size()); |
| |
| StackString<128> accept_hdr("Sec-WebSocket-Accept: %s", digest_b64.c_str()); |
| |
| std::initializer_list<const char*> headers = { |
| "Upgrade: websocket", // |
| "Connection: Upgrade", // |
| accept_hdr.c_str(), // |
| }; |
| PERFETTO_DLOG("[HTTP] Handshaking WebSocket for %.*s", |
| static_cast<int>(req.uri.size()), req.uri.data()); |
| for (const char* hdr : headers) |
| PERFETTO_DLOG("> %s", hdr); |
| |
| SendResponseHeaders("101 Switching Protocols", headers, |
| HttpServerConnection::kOmitContentLength); |
| |
| is_websocket_ = true; |
| } |
| |
| size_t HttpServer::ParseOneWebsocketFrame(HttpServerConnection* conn) { |
| auto* rxbuf = reinterpret_cast<uint8_t*>(conn->rxbuf.Get()); |
| const size_t frame_size = conn->rxbuf_used; |
| uint8_t* rd = rxbuf; |
| uint8_t* const end = rxbuf + frame_size; |
| |
| auto avail = [&] { |
| PERFETTO_CHECK(rd <= end); |
| return static_cast<size_t>(end - rd); |
| }; |
| |
| // From https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 : |
| // 0 1 2 3 |
| // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 |
| // +-+-+-+-+-------+-+-------------+-------------------------------+ |
| // |F|R|R|R| opcode|M| Payload len | Extended payload length | |
| // |I|S|S|S| (4) |A| (7) | (16/64) | |
| // |N|V|V|V| |S| | (if payload len==126/127) | |
| // | |1|2|3| |K| | | |
| // +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + |
| // | Extended payload length continued, if payload len == 127 | |
| // + - - - - - - - - - - - - - - - +-------------------------------+ |
| // | |Masking-key, if MASK set to 1 | |
| // +-------------------------------+-------------------------------+ |
| // | Masking-key (continued) | Payload Data | |
| // +-------------------------------- - - - - - - - - - - - - - - - + |
| // : Payload Data continued ... : |
| // + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + |
| // | Payload Data continued ... | |
| // +---------------------------------------------------------------+ |
| |
| if (avail() < 2) |
| return 0; // Can't even decode the frame header. Wait for more data. |
| |
| uint8_t h0 = *(rd++); |
| uint8_t h1 = *(rd++); |
| const bool fin = !!(h0 & 0x80); // This bit is set if this frame is the last |
| // data to complete this message. |
| const uint8_t opcode = h0 & 0x0F; |
| |
| const bool has_mask = !!(h1 & 0x80); |
| uint64_t payload_len_u64 = (h1 & 0x7F); |
| uint8_t extended_payload_size = 0; |
| if (payload_len_u64 == 126) { |
| extended_payload_size = 2; |
| } else if (payload_len_u64 == 127) { |
| extended_payload_size = 8; |
| } |
| |
| if (extended_payload_size > 0) { |
| if (avail() < extended_payload_size) |
| return 0; // Not enough data to read the extended header. |
| payload_len_u64 = 0; |
| for (uint8_t i = 0; i < extended_payload_size; ++i) { |
| payload_len_u64 <<= 8; |
| payload_len_u64 |= *(rd++); |
| } |
| } |
| |
| if (payload_len_u64 >= kMaxPayloadSize) { |
| PERFETTO_ELOG("[HTTP] Websocket payload too big (%" PRIu64 " > %zu)", |
| payload_len_u64, kMaxPayloadSize); |
| conn->Close(); |
| return 0; |
| } |
| const size_t payload_len = static_cast<size_t>(payload_len_u64); |
| |
| if (!has_mask) { |
| // https://datatracker.ietf.org/doc/html/rfc6455#section-5.1 |
| // The server MUST close the connection upon receiving a frame that is |
| // not masked. |
| PERFETTO_ELOG("[HTTP] Websocket inbound frames must be masked"); |
| conn->Close(); |
| return 0; |
| } |
| |
| uint8_t mask[4]; |
| if (avail() < sizeof(mask)) |
| return 0; // Not enough data to read the masking key. |
| memcpy(mask, rd, sizeof(mask)); |
| rd += sizeof(mask); |
| |
| PERFETTO_DLOG( |
| "[HTTP] Websocket fin=%d opcode=%u, payload_len=%zu (avail=%zu), " |
| "mask=%02x%02x%02x%02x", |
| fin, opcode, payload_len, avail(), mask[0], mask[1], mask[2], mask[3]); |
| |
| if (avail() < payload_len) |
| return 0; // Not enouh data to read the payload. |
| uint8_t* const payload_start = rd; |
| |
| // Unmask the payload. |
| for (uint32_t i = 0; i < payload_len; ++i) |
| payload_start[i] ^= mask[i % sizeof(mask)]; |
| |
| if (opcode == kOpcodePing) { |
| PERFETTO_DLOG("[HTTP] Websocket PING"); |
| conn->SendWebsocketFrame(kOpcodePong, payload_start, payload_len); |
| } else if (opcode == kOpcodeBinary || opcode == kOpcodeText || |
| opcode == kOpcodeContinuation) { |
| // We do NOT handle fragmentation. We propagate all fragments as individual |
| // messages, breaking the message-oriented nature of websockets. We do this |
| // because in all our use cases we need only a byte stream without caring |
| // about message boundaries. |
| // If we wanted to support fragmentation, we'd have to stash |
| // kOpcodeContinuation messages in a buffer, until we FIN bit is set. |
| // When loading traces with trace processor, the messages can be up to |
| // 32MB big (SLICE_SIZE in trace_stream.ts). The double-buffering would |
| // slow down significantly trace loading with no benefits. |
| WebsocketMessage msg(conn); |
| msg.data = |
| StringView(reinterpret_cast<const char*>(payload_start), payload_len); |
| msg.is_text = opcode == kOpcodeText; |
| req_handler_->OnWebsocketMessage(msg); |
| } else if (opcode == kOpcodeClose) { |
| conn->Close(); |
| } else { |
| PERFETTO_LOG("Unsupported WebSocket opcode: %d", opcode); |
| } |
| return static_cast<size_t>(rd - rxbuf) + payload_len; |
| } |
| |
| void HttpServerConnection::SendResponseHeaders( |
| const char* http_code, |
| std::initializer_list<const char*> headers, |
| size_t content_length) { |
| PERFETTO_CHECK(!headers_sent_); |
| PERFETTO_CHECK(!is_websocket_); |
| headers_sent_ = true; |
| std::vector<char> resp_hdr; |
| resp_hdr.reserve(512); |
| bool has_connection_header = false; |
| |
| auto append = [&resp_hdr](const char* str) { |
| resp_hdr.insert(resp_hdr.end(), str, str + strlen(str)); |
| }; |
| |
| append("HTTP/1.1 "); |
| append(http_code); |
| append("\r\n"); |
| for (const char* hdr : headers) { |
| if (strlen(hdr) == 0) |
| continue; |
| has_connection_header |= strncasecmp(hdr, "connection:", 11) == 0; |
| append(hdr); |
| append("\r\n"); |
| } |
| content_len_actual_ = 0; |
| content_len_headers_ = content_length; |
| if (content_length != kOmitContentLength) { |
| append("Content-Length: "); |
| append(std::to_string(content_length).c_str()); |
| append("\r\n"); |
| } |
| if (!has_connection_header) { |
| // Various clients (e.g., python's http.client) assume that a HTTP |
| // connection is keep-alive if the server says nothing, even when they do |
| // NOT ask for it. Hence we must be explicit. If we are about to close the |
| // connection, we must say so. |
| append(keepalive_ ? "Connection: keep-alive\r\n" : "Connection: close\r\n"); |
| } |
| if (!origin_allowed_.empty()) { |
| append("Access-Control-Allow-Origin: "); |
| append(origin_allowed_.c_str()); |
| append("\r\n"); |
| append("Vary: Origin\r\n"); |
| } |
| append("\r\n"); // End-of-headers marker. |
| sock->Send(resp_hdr.data(), |
| resp_hdr.size()); // Send response headers. |
| } |
| |
| void HttpServerConnection::SendResponseBody(const void* data, size_t len) { |
| PERFETTO_CHECK(!is_websocket_); |
| if (data == nullptr) { |
| PERFETTO_DCHECK(len == 0); |
| return; |
| } |
| content_len_actual_ += len; |
| PERFETTO_CHECK(content_len_actual_ <= content_len_headers_ || |
| content_len_headers_ == kOmitContentLength); |
| sock->Send(data, len); |
| } |
| |
| void HttpServerConnection::Close() { |
| sock->Shutdown(/*notify=*/true); |
| } |
| |
| void HttpServerConnection::SendResponse( |
| const char* http_code, |
| std::initializer_list<const char*> headers, |
| StringView content, |
| bool force_close) { |
| if (force_close) |
| keepalive_ = false; |
| SendResponseHeaders(http_code, headers, content.size()); |
| SendResponseBody(content.data(), content.size()); |
| if (!keepalive_) |
| Close(); |
| } |
| |
| void HttpServerConnection::SendWebsocketMessage(const void* data, size_t len) { |
| SendWebsocketFrame(kOpcodeBinary, data, len); |
| } |
| |
| void HttpServerConnection::SendWebsocketFrame(uint8_t opcode, |
| const void* payload, |
| size_t payload_len) { |
| PERFETTO_CHECK(is_websocket_); |
| |
| uint8_t hdr[10]{}; |
| uint32_t hdr_len = 0; |
| |
| hdr[0] = opcode | 0x80 /* FIN=1, no fragmentation */; |
| if (payload_len < 126) { |
| hdr_len = 2; |
| hdr[1] = static_cast<uint8_t>(payload_len); |
| } else if (payload_len < 0xffff) { |
| hdr_len = 4; |
| hdr[1] = 126; // Special value: Header extends for 2 bytes. |
| uint16_t len_be = HostToBE16(static_cast<uint16_t>(payload_len)); |
| memcpy(&hdr[2], &len_be, sizeof(len_be)); |
| } else { |
| hdr_len = 10; |
| hdr[1] = 127; // Special value: Header extends for 4 bytes. |
| uint64_t len_be = HostToBE64(payload_len); |
| memcpy(&hdr[2], &len_be, sizeof(len_be)); |
| } |
| |
| sock->Send(hdr, hdr_len); |
| if (payload && payload_len > 0) |
| sock->Send(payload, payload_len); |
| } |
| |
| HttpServerConnection::HttpServerConnection(std::unique_ptr<UnixSocket> s) |
| : sock(std::move(s)), rxbuf(PagedMemory::Allocate(kMaxRequestSize)) {} |
| |
| HttpServerConnection::~HttpServerConnection() = default; |
| |
| Optional<StringView> HttpRequest::GetHeader(StringView name) const { |
| for (size_t i = 0; i < num_headers; i++) { |
| if (headers[i].name.CaseInsensitiveEq(name)) |
| return headers[i].value; |
| } |
| return nullopt; |
| } |
| |
| HttpRequestHandler::~HttpRequestHandler() = default; |
| void HttpRequestHandler::OnWebsocketMessage(const WebsocketMessage&) {} |
| void HttpRequestHandler::OnHttpConnectionClosed(HttpServerConnection*) {} |
| |
| } // namespace base |
| } // namespace perfetto |