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.
This commit is contained in:
yhirose
2026-10-11 23:05:46 -04:00
parent 5189edc52e
commit 1f12bbaabb
2 changed files with 46 additions and 8 deletions
+15 -8
View File
@@ -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) {
+31
View File
@@ -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<std::string> 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<size_t>(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"