Highlighter engine returns cached spans by reference; drop unneeded mutexes

HighlighterEngine::GetLine copied the span vector on every call (twice
on a miss) and did a dynamic_cast per call; it now returns the cached
entry (valid until the next call) and dispatches on Stateful().

PieceTable and HighlighterEngine took a mutex in their read paths while
their writes took none, and PieceTable.h claimed concurrent reads were
safe; they are not (reads update caches). Every Buffer is used from the
main thread only (the swap writer thread receives copies; the regex
large-stack thread runs while its caller waits), so the mutexes are
removed and the headers say so. ASan and the threaded tests under TSan
are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 12:19:59 -07:00
committed by Kyle Isom
parent 7039c14507
commit d490245f30
8 changed files with 43 additions and 73 deletions
-1
View File
@@ -44,7 +44,6 @@
#include <cstdint> #include <cstdint>
#include "syntax/HighlighterEngine.h" #include "syntax/HighlighterEngine.h"
#include "Highlight.h" #include "Highlight.h"
#include <mutex>
// Edit mode determines which font class is used for a buffer. // Edit mode determines which font class is used for a buffer.
enum class EditMode { Code, Writing }; enum class EditMode { Code, Writing };
+1 -1
View File
@@ -438,7 +438,7 @@ ImGuiRenderer::Draw(Editor &ed)
} }
// Draw syntax-colored runs (text above background highlights) // Draw syntax-colored runs (text above background highlights)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine( const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version()); *buf, static_cast<int>(i), buf->Version());
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties // Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
struct SSpan { struct SSpan {
-10
View File
@@ -321,7 +321,6 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
void void
PieceTable::materialize() const PieceTable::materialize() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!dirty_) { if (!dirty_) {
return; return;
} }
@@ -401,7 +400,6 @@ PieceTable::coalesceNeighbors(std::size_t index)
void void
PieceTable::InvalidateLineIndex() const PieceTable::InvalidateLineIndex() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
line_index_dirty_ = true; line_index_dirty_ = true;
} }
@@ -435,7 +433,6 @@ PieceTable::applyLineShift() const
void void
PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (line_index_dirty_) if (line_index_dirty_)
return; return;
// Line starts after the insertion point move right; a start exactly at // Line starts after the insertion point move right; a start exactly at
@@ -461,7 +458,6 @@ PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t
void void
PieceTable::lineIndexOnDelete(std::size_t offset, std::size_t len) const PieceTable::lineIndexOnDelete(std::size_t offset, std::size_t len) const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (line_index_dirty_) if (line_index_dirty_)
return; return;
// Starts in (offset, offset+len] followed a deleted newline: drop them. // Starts in (offset, offset+len] followed a deleted newline: drop them.
@@ -482,7 +478,6 @@ PieceTable::lineIndexOnDelete(std::size_t offset, std::size_t len) const
void void
PieceTable::RebuildLineIndex() const PieceTable::RebuildLineIndex() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!line_index_dirty_) { if (!line_index_dirty_) {
return; return;
@@ -930,7 +925,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
// Fast path: return cached value if version/offset/len match // Fast path: return cached value if version/offset/len match
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (range_cache_.valid && range_cache_.version == version_ && if (range_cache_.valid && range_cache_.version == version_ &&
range_cache_.off == byte_offset && range_cache_.len == len) { range_cache_.off == byte_offset && range_cache_.len == len) {
return range_cache_.data; return range_cache_.data;
@@ -940,7 +934,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
std::string out; std::string out;
out.reserve(len); out.reserve(len);
if (!dirty_) { if (!dirty_) {
std::lock_guard<std::mutex> lock(mutex_);
// Already materialized; slice directly // Already materialized; slice directly
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len); out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
} else { } else {
@@ -965,7 +958,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
// Update cache // Update cache
{ {
std::lock_guard<std::mutex> lock(mutex_);
range_cache_.valid = true; range_cache_.valid = true;
range_cache_.version = version_; range_cache_.version = version_;
range_cache_.off = byte_offset; range_cache_.off = byte_offset;
@@ -984,7 +976,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
if (start > total_size_) if (start > total_size_)
return std::numeric_limits<std::size_t>::max(); return std::numeric_limits<std::size_t>::max();
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (find_cache_.valid && if (find_cache_.valid &&
find_cache_.version == version_ && find_cache_.version == version_ &&
find_cache_.needle == needle && find_cache_.needle == needle &&
@@ -996,7 +987,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
materialize(); materialize();
std::size_t pos; std::size_t pos;
{ {
std::lock_guard<std::mutex> lock(mutex_);
pos = materialized_.find(needle, start); pos = materialized_.find(needle, start);
if (pos == std::string::npos) if (pos == std::string::npos)
pos = std::numeric_limits<std::size_t>::max(); pos = std::numeric_limits<std::size_t>::max();
+2 -4
View File
@@ -33,7 +33,8 @@
* *
* Implementation notes: * Implementation notes:
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams) * - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds) * - Not thread-safe: even reads update caches. Buffers are used from the
* main thread only (the swap writer thread gets copies of the bytes).
* - Version tracking invalidates caches on mutations * - Version tracking invalidates caches on mutations
*/ */
#pragma once #pragma once
@@ -43,7 +44,6 @@
#include <ostream> #include <ostream>
#include <vector> #include <vector>
#include <limits> #include <limits>
#include <mutex>
#include <string_view> #include <string_view>
#include "TextSpan.h" #include "TextSpan.h"
@@ -297,6 +297,4 @@ private:
mutable RangeCache range_cache_; mutable RangeCache range_cache_;
mutable FindCache find_cache_; mutable FindCache find_cache_;
mutable std::mutex mutex_;
}; };
+1 -1
View File
@@ -308,7 +308,7 @@ protected:
// as operators). // as operators).
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()-> if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) { HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine( const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version()); *buf, static_cast<int>(i), buf->Version());
struct SSpan { struct SSpan {
std::size_t s; std::size_t s;
+1 -1
View File
@@ -163,7 +163,7 @@ TerminalRenderer::Draw(Editor &ed)
std::vector<kte::HighlightSpan> sane_spans; std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()-> if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) { HasHighlighter()) {
kte::LineHighlight lh_val = buf->Highlighter()->GetLine( const kte::LineHighlight &lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version()); *buf, static_cast<int>(li), buf->Version());
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties // Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
const std::size_t line_len = line.size(); const std::size_t line_len = line.size();
+15 -29
View File
@@ -14,7 +14,6 @@ HighlighterEngine::~HighlighterEngine() = default;
void void
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl) HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
{ {
std::lock_guard<std::mutex> lock(mtx_);
hl_ = std::move(hl); hl_ = std::move(hl);
clear_caches_locked(); clear_caches_locked();
} }
@@ -39,38 +38,26 @@ HighlighterEngine::invalidate_from_locked(int row) const
} }
LineHighlight const LineHighlight &
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
{ {
std::lock_guard<std::mutex> lock(mtx_);
if (!have_version_ || buf_version != version_) { if (!have_version_ || buf_version != version_) {
// Content changed without OnEdit: nothing cached can be trusted. // Content changed without OnEdit: nothing cached can be trusted.
clear_caches_locked(); clear_caches_locked();
version_ = buf_version; version_ = buf_version;
have_version_ = true; have_version_ = true;
} }
auto it = cache_.find(row); if (auto it = cache_.find(row); it != cache_.end())
if (it != cache_.end()) return it->second;
return it->second; // return by value (copy)
LineHighlight result; LineHighlight result;
result.version = buf_version; result.version = buf_version;
if (hl_ && row >= 0 && !hl_->Stateful()) {
if (!hl_ || row < 0) {
cache_[row] = result;
return result;
}
auto *stateful = dynamic_cast<StatefulHighlighter *>(hl_.get());
if (!stateful) {
hl_->HighlightLine(buf, row, result.spans); hl_->HighlightLine(buf, row, result.spans);
cache_[row] = result; } else if (hl_ && row >= 0) {
trim_cache_locked(row); const auto *stateful = static_cast<const StatefulHighlighter *>(hl_.get());
return result; // Continue from the last row with a known end state (or the row
} // just above the target, if that is known), caching every row's
// Stateful: continue from the last row with a known end state (or the
// row just above the target, if that is known), caching every row's
// spans and state along the way so neighbouring rows are cache hits. // spans and state along the way so neighbouring rows are cache hits.
const int known = static_cast<int>(states_.size()) - 1; const int known = static_cast<int>(states_.size()) - 1;
const int start = std::min(known, row - 1); const int start = std::min(known, row - 1);
@@ -86,16 +73,17 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
else else
states_[static_cast<std::size_t>(r)] = state; states_[static_cast<std::size_t>(r)] = state;
if (r == row) if (r == row)
result = lh; result = std::move(lh);
else if (row - r <= kCacheNear) else if (row - r <= kCacheNear)
cache_.insert_or_assign(r, std::move(lh)); cache_.insert_or_assign(r, std::move(lh));
// Rows further above only needed their end state (kept in states_): // Rows further above only needed their end state (kept in
// caching their spans too held about 10x the file size in memory // states_): caching their spans too held about 10x the file size
// after jumping to the end of a large file. // in memory after jumping to the end of a large file.
} }
cache_[row] = result; }
// Trim first: the entry returned must survive until the next call.
trim_cache_locked(row); trim_cache_locked(row);
return result; return cache_.insert_or_assign(row, std::move(result)).first->second;
} }
@@ -112,7 +100,6 @@ HighlighterEngine::trim_cache_locked(const int row) const
void void
HighlighterEngine::InvalidateFrom(int row) HighlighterEngine::InvalidateFrom(int row)
{ {
std::lock_guard<std::mutex> lock(mtx_);
invalidate_from_locked(row); invalidate_from_locked(row);
} }
@@ -120,7 +107,6 @@ HighlighterEngine::InvalidateFrom(int row)
void void
HighlighterEngine::OnEdit(int row, std::uint64_t buf_version) HighlighterEngine::OnEdit(int row, std::uint64_t buf_version)
{ {
std::lock_guard<std::mutex> lock(mtx_);
if (have_version_) if (have_version_)
invalidate_from_locked(row); invalidate_from_locked(row);
version_ = buf_version; version_ = buf_version;
+6 -9
View File
@@ -1,11 +1,11 @@
// HighlighterEngine.h - caching layer for per-line highlights // HighlighterEngine.h - caching layer for per-line highlights (main thread
// only, like the Buffer it reads)
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <map> #include <map>
#include <memory> #include <memory>
#include <vector> #include <vector>
#include <mutex>
#include "../Highlight.h" #include "../Highlight.h"
#include "LanguageHighlighter.h" #include "LanguageHighlighter.h"
@@ -21,10 +21,10 @@ public:
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl); void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
// Retrieve highlights for a given line and buffer version. // Highlights for a line at a buffer version, computed if not cached.
// Returns a copy to avoid lifetime issues across threads/renderers. // The reference is valid until the next call on this engine (a later
// If cache is stale, recompute using the current highlighter. // lookup may evict the entry); copy it to keep it longer.
LineHighlight GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const; const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
// Invalidate cached lines from row (inclusive) // Invalidate cached lines from row (inclusive)
void InvalidateFrom(int row); void InvalidateFrom(int row);
@@ -74,8 +74,5 @@ private:
static constexpr std::size_t kCacheMax = 16384; static constexpr std::size_t kCacheMax = 16384;
void trim_cache_locked(int row) const; void trim_cache_locked(int row) const;
// Guards the caches above.
mutable std::mutex mtx_;
}; };
} // namespace kte } // namespace kte