From 1f12bbaabb0ddee22fd3bdc8add74cf0b992633b Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 11 Oct 2026 23:05:46 -0400 Subject: [PATCH] Treat a bare CR as an SSE line ending The SSE spec ends a line with CRLF, LF or CR, but SSEClient split only on LF, so a stream that uses CR alone never produced an event. The LF of a CRLF is skipped even when it arrives in a later chunk than its CR. Search for a line ending only in the newly received data. What is left in the buffer has none, and scanning it again for every chunk made a long line quadratic. --- httplib.h | 23 +++++++++++++++-------- test/test.cc | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/httplib.h b/httplib.h index ba3fa7e6..0114e27f 100644 --- a/httplib.h +++ b/httplib.h @@ -4997,22 +4997,29 @@ inline void SSEClient::run_event_loop() { auto has_data = false; auto has_id = false; auto first_line = true; + auto after_cr = false; while (running_.load() && result.next()) { + // What is left in the buffer has no line ending + auto search_pos = buffer.size(); buffer.append(result.data(), result.size()); - // Process complete lines in the buffer + // Process complete lines in the buffer. A line ends with \r\n, \n or \r + // per the SSE spec size_t line_start = 0; - size_t newline_pos; + size_t eol_pos; - while ((newline_pos = buffer.find('\n', line_start)) != + while ((eol_pos = buffer.find_first_of("\r\n", search_pos)) != std::string::npos) { - auto line = buffer.substr(line_start, newline_pos - line_start); - line_start = newline_pos + 1; + auto line = buffer.substr(line_start, eol_pos - line_start); + line_start = eol_pos + 1; + search_pos = line_start; - // Strip the \r of a CRLF line ending so that every field, including - // one without a colon, sees the same line - if (!line.empty() && line.back() == '\r') { line.pop_back(); } + // The \n of a \r\n may arrive in a later chunk than its \r + auto is_lf_of_crlf = + after_cr && line.empty() && buffer[eol_pos] == '\n'; + after_cr = buffer[eol_pos] == '\r'; + if (is_lf_of_crlf) { continue; } // A UTF-8 BOM at the start of the stream is ignored per the SSE spec if (first_line) { diff --git a/test/test.cc b/test/test.cc index a368a91f..33a91092 100644 --- a/test/test.cc +++ b/test/test.cc @@ -22793,6 +22793,37 @@ TEST_F(SSEParsingTest, FieldNameOnlyWithCarriageReturn) { EXPECT_EQ(msgs[0].data, ""); } +TEST_F(SSEParsingTest, CarriageReturnLineEnding) { + auto msgs = parse("event: update\rdata: hello\r\r"); + ASSERT_EQ(msgs.size(), 1u); + EXPECT_EQ(msgs[0].event, "update"); + EXPECT_EQ(msgs[0].data, "hello"); +} + +TEST_F(SSEParsingTest, LineEndingSplitAcrossChunks) { + // Chunk boundaries fall inside every \r\n and right before the first one + std::vector chunks = {"event: update", "\r", "\ndata: hello\r", + "\n\r", "\nevent: end\ndata: end\n\n"}; + server_->Get("/split", [chunks](const Request &, Response &res) { + auto i = std::make_shared(0); + res.set_chunked_content_provider( + "text/event-stream", [chunks, i](size_t /*offset*/, DataSink &sink) { + if (*i == chunks.size()) { + sink.done(); + } else { + sink.write(chunks[*i].data(), chunks[*i].size()); + (*i)++; + } + return true; + }); + }); + + auto msgs = collect("/split"); + ASSERT_EQ(msgs.size(), 1u); + EXPECT_EQ(msgs[0].event, "update"); + EXPECT_EQ(msgs[0].data, "hello"); +} + TEST_F(SSEParsingTest, LeadingByteOrderMarkIgnored) { // Only a BOM at the start of the stream is ignored auto msgs = parse("\xEF\xBB\xBF"