diff --git a/src/ui/windows/console_model.cpp b/src/ui/windows/console_model.cpp index a5cfa17..d863822 100644 --- a/src/ui/windows/console_model.cpp +++ b/src/ui/windows/console_model.cpp @@ -9,27 +9,60 @@ namespace dragonx { namespace ui { +void ConsoleModel::trimPendingLocked() +{ + // Keep at least the newest line so one huge line is never dropped on arrival. Dropping the OLDEST pending + // lines past max_lines_ is lossless for the view (drain() would evict them immediately); the byte budget + // is the only lossy part and only bites while nobody drains (Console page closed). + while (pending_.size() > 1 && (pending_.size() > max_lines_ || pending_bytes_ > max_pending_bytes_)) { + pending_bytes_ -= pending_.front().text.size(); + pending_.pop_front(); + ++dropped_; + } +} + void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel, int foldSpan) { - std::lock_guard lock(ingest_mutex_); - ConsoleModelLine line; + ConsoleModelLine line; // build outside the lock line.text = text; line.channel = channel; line.foldSpan = foldSpan; + const std::size_t bytes = line.text.size(); + std::lock_guard lock(ingest_mutex_); pending_.push_back(std::move(line)); + pending_bytes_ += bytes; + trimPendingLocked(); +} + +void ConsoleModel::ingestBatch(std::vector&& lines) +{ + if (lines.empty()) return; + std::size_t bytes = 0; + for (const auto& l : lines) bytes += l.text.size(); + std::lock_guard lock(ingest_mutex_); // ONE acquisition: the block stays contiguous + for (auto& l : lines) pending_.push_back(std::move(l)); + pending_bytes_ += bytes; + trimPendingLocked(); } ConsoleModel::DrainResult ConsoleModel::drain() { - std::vector incoming; + std::deque incoming; + std::size_t dropped = 0; { std::lock_guard lock(ingest_mutex_); if (pending_.empty()) return {}; incoming.swap(pending_); + pending_bytes_ = 0; + dropped = dropped_; + dropped_ = 0; } DrainResult result; result.added = incoming.size(); + result.dropped = dropped; + // A dropped pending line never entered lines_, so it does not advance first_seq_ (the seq of + // lines_.front()); only a front eviction below or clear() does. Keeps the wrap cache aligned. for (auto& line : incoming) lines_.push_back(std::move(line)); diff --git a/src/ui/windows/console_model.h b/src/ui/windows/console_model.h index 552e92a..d639a99 100644 --- a/src/ui/windows/console_model.h +++ b/src/ui/windows/console_model.h @@ -45,16 +45,31 @@ class ConsoleModel { public: static constexpr std::size_t kDefaultMaxLines = 10000; - explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines) : max_lines_(maxLines) {} + // Pending (not yet drained) text is bounded too: lines beyond max_lines_ would be evicted by drain() anyway, + // and a byte budget stops a hidden Console from retaining verbose daemon-log chunks (<=4 KB each) forever. + static constexpr std::size_t kDefaultMaxPendingBytes = 4u * 1024u * 1024u; + + explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines, + std::size_t maxPendingBytes = kDefaultMaxPendingBytes) + : max_lines_(maxLines), max_pending_bytes_(maxPendingBytes) {} + + std::size_t maxLines() const { return max_lines_; } // Append a line from ANY thread. Cheap: locks only the pending queue, does not touch // the visible deque. The line becomes visible after the next drain(). `foldSpan` marks a // JSON block opener (offset to its matching closer); 0 for ordinary lines. void ingest(const std::string& text, ConsoleChannel channel, int foldSpan = 0); + // Append a contiguous block (e.g. one formatted RPC result) from ANY thread under ONE lock acquisition, so + // lines from other producers can never land inside it (foldSpan offsets are relative to the opener). + // Never calls the Logger (Logger::write invokes its callback while holding Logger::mutex_, i.e. the lock + // order is Logger::mutex_ -> ingest_mutex_; logging from in here would self-deadlock). + void ingestBatch(std::vector&& lines); + struct DrainResult { - std::size_t added = 0; // lines appended to the visible deque this drain - std::size_t popped = 0; // lines evicted from the front by the line cap + std::size_t added = 0; // lines appended to the visible deque this drain + std::size_t popped = 0; // lines evicted from the front by the line cap + std::size_t dropped = 0; // oldest pending lines discarded by the pending cap/byte budget since last drain }; // Main thread only. Move pending lines into the visible deque, then enforce the cap. @@ -87,9 +102,14 @@ public: std::uint64_t firstSeq() const { return first_seq_; } private: + void trimPendingLocked(); // caller holds ingest_mutex_ + const std::size_t max_lines_; + const std::size_t max_pending_bytes_; std::deque lines_; // visible model — main thread only - std::vector pending_; // guarded by ingest_mutex_ + std::deque pending_; // guarded by ingest_mutex_ (deque: O(1) pop_front when trimming) + std::size_t pending_bytes_ = 0; // sum of pending_ text sizes, guarded by ingest_mutex_ + std::size_t dropped_ = 0; // guarded by ingest_mutex_, reset by drain() std::mutex ingest_mutex_; std::uint64_t revision_ = 0; std::uint64_t first_seq_ = 0; // see firstSeq() diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index fb1c253..fc8981d 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -2056,12 +2056,14 @@ void ConsoleTab::addFormattedResult(const std::string& result, bool is_error) // Precompute JSON fold spans over the whole block so each opener line carries the offset // to its matching closer (the spans are relative, so they stay valid as the model's line - // cap evicts from the front). This block is ingested atomically on the main thread. + // cap evicts from the front). The block is ingested under one lock (ingestBatch). std::vector texts; texts.reserve(resultLines.size()); for (const auto& rl : resultLines) texts.push_back(rl.text); const std::vector foldSpans = ComputeConsoleFoldSpans(texts); + std::vector block; + block.reserve(resultLines.size()); for (std::size_t i = 0; i < resultLines.size(); ++i) { ConsoleChannel channel = ConsoleChannel::None; switch (resultLines[i].role) { @@ -2072,8 +2074,13 @@ void ConsoleTab::addFormattedResult(const std::string& result, bool is_error) case ConsoleResultLineRole::JsonBrace: channel = ConsoleChannel::JsonBrace; break; case ConsoleResultLineRole::Result: break; } - model_.ingest(resultLines[i].text, channel, foldSpans[i]); + ConsoleModelLine ml; + ml.text = resultLines[i].text; + ml.channel = channel; + ml.foldSpan = foldSpans[i]; + block.push_back(std::move(ml)); } + model_.ingestBatch(std::move(block)); } void ConsoleTab::renderStatusHeader(ConsoleCommandExecutor& exec) diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 0b02b03..8e339ab 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -7313,19 +7313,22 @@ void testConsoleWrapCache() EXPECT_EQ(c2.size(), static_cast(0)); } - // 7) ConsoleModel::firstSeq: +1 per evicted line, +size() on clear(), untouched by fold toggles. + // 7) ConsoleModel::firstSeq advances only when a line leaves the FRONT of lines_ (cap eviction or + // clear); a pending line dropped by the byte/line budget never entered lines_ and does not advance + // it. Untouched by fold toggles. (cap 3: 0..4 ingested -> pending drops "0","1" before they reach + // lines_, so lines_=[2,3,4] with zero front evictions -> firstSeq 0.) { ConsoleModel cm(3); for (int i = 0; i < 5; ++i) cm.ingest(std::to_string(i), ConsoleChannel::None); cm.drain(); - EXPECT_EQ(cm.firstSeq(), static_cast(2)); + EXPECT_EQ(cm.firstSeq(), static_cast(0)); // "2","3","4" land; nothing evicted from front cm.ingest("{", ConsoleChannel::JsonBrace, 2); cm.drain(); - EXPECT_EQ(cm.firstSeq(), static_cast(3)); + EXPECT_EQ(cm.firstSeq(), static_cast(1)); // "{" pushes over cap -> evict "2" from front cm.toggleCollapsed(2); - EXPECT_EQ(cm.firstSeq(), static_cast(3)); + EXPECT_EQ(cm.firstSeq(), static_cast(1)); cm.clear(); - EXPECT_EQ(cm.firstSeq(), static_cast(6)); + EXPECT_EQ(cm.firstSeq(), static_cast(4)); // + lines_.size() (3) on clear } } @@ -7536,13 +7539,34 @@ void testConsoleModel() ConsoleModel m(3); // cap = 3 for (int i = 0; i < 5; ++i) m.ingest(std::to_string(i), ConsoleChannel::None); auto dr = m.drain(); - EXPECT_EQ(dr.added, static_cast(5)); - EXPECT_EQ(dr.popped, static_cast(2)); + EXPECT_EQ(dr.added, static_cast(3)); // pending is capped at ingest: "0","1" never reach the deque + EXPECT_EQ(dr.popped, static_cast(0)); + EXPECT_EQ(dr.dropped, static_cast(2)); EXPECT_EQ(m.size(), static_cast(3)); EXPECT_EQ(m[0].text, std::string("2")); // "0" and "1" evicted EXPECT_EQ(m[2].text, std::string("4")); } + // Pending byte budget keeps the newest lines; a single oversized line is never dropped. + { + ConsoleModel m(100, 10); + for (int i = 0; i < 5; ++i) m.ingest("abcd", ConsoleChannel::None); + auto dr = m.drain(); + EXPECT_EQ(dr.added, static_cast(2)); + EXPECT_EQ(dr.dropped, static_cast(3)); + ConsoleModel big(100, 10); + big.ingest(std::string(50, 'x'), ConsoleChannel::None); + EXPECT_EQ(big.drain().added, static_cast(1)); + // ingestBatch appends a block contiguously. + std::vector blk(3); + blk[0].text = "B0"; blk[1].text = "B1"; blk[2].text = "B2"; + ConsoleModel bm; + bm.ingestBatch(std::move(blk)); + bm.drain(); + EXPECT_EQ(bm.size(), static_cast(3)); + EXPECT_EQ(bm[2].text, std::string("B2")); + } + // clear() drops the visible lines but not lines still queued for the next drain. { ConsoleModel m;