fix(console): bound the pending queue by lines and bytes

The console's pending, not-yet-drained queue was unbounded, so a hidden
Console page that kept receiving verbose daemon-log chunks could grow
without limit.

- Pending lines are now bounded by both a line count and a byte budget.
  The oldest pending lines are dropped once either is exceeded, while
  the newest line is always kept so one huge line is never lost.
- A formatted RPC result is ingested as one block under a single lock,
  so lines from other producers can never interleave inside it.

The front-sequence accounting is also corrected: a pending line dropped
by the cap never entered the visible buffer, so it no longer advances
the front sequence. Only a front eviction or a clear does.

Tests: the line and byte caps keep the newest lines, a single oversized
line survives, a batch appends contiguously, and the front sequence
advances only on a front eviction or clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 08:23:30 -05:00
co-authored by Claude Opus 4.8
parent 70e96f75f0
commit 116a997a3a
4 changed files with 100 additions and 16 deletions
+24 -4
View File
@@ -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<ConsoleModelLine>&& 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<ConsoleModelLine> lines_; // visible model — main thread only
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
std::deque<ConsoleModelLine> 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()