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>
120 lines
5.9 KiB
C++
120 lines
5.9 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
//
|
|
// ConsoleModel — the thread-safe line store behind ConsoleTab.
|
|
//
|
|
// The console is fed from multiple threads: user commands + drained daemon/lite log lines
|
|
// arrive on the main (render) thread, but RPC-trace and app-logger lines can be produced on
|
|
// background worker threads. Previously a single mutex guarded both the line container AND
|
|
// the UI-only interaction state (selection, scroll, auto-scroll counters), and it was held
|
|
// across the whole render pass — yet some of that UI state was still touched outside the
|
|
// lock in render(), racing the background producers.
|
|
//
|
|
// ConsoleModel splits the two concerns. Producers on ANY thread call ingest(), which only
|
|
// briefly locks a small pending queue. The main thread calls drain() once per frame to move
|
|
// pending lines into the visible deque and apply the line cap. The visible deque is then
|
|
// only ever read/written on the main thread, so ConsoleTab needs no lock during render and
|
|
// its selection/scroll fields become pure main-thread state.
|
|
|
|
#pragma once
|
|
|
|
#include "console_channel.h"
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <deque>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
struct ConsoleModelLine {
|
|
std::string text;
|
|
ConsoleChannel channel = ConsoleChannel::None;
|
|
// JSON folding: for a line that opens a collapsible block, `foldSpan` is the offset to
|
|
// its matching closing-bracket line (>= 2); 0 for non-openers. `collapsed` is the user's
|
|
// fold toggle (main-thread UI state).
|
|
int foldSpan = 0;
|
|
bool collapsed = false;
|
|
};
|
|
|
|
class ConsoleModel {
|
|
public:
|
|
static constexpr std::size_t kDefaultMaxLines = 10000;
|
|
|
|
// 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 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.
|
|
// Returns how many were added and how many were evicted from the front (so the caller
|
|
// can shift any selection indices that referenced the evicted lines).
|
|
DrainResult drain();
|
|
|
|
// Main thread only. Clear the visible deque. Does NOT discard queued pending lines —
|
|
// new output still appears — matching the console's view-only `clear` semantics.
|
|
void clear();
|
|
|
|
// Main thread only. Toggle a foldable line's collapsed state (no-op if out of range or
|
|
// not a block opener). Returns the new collapsed state.
|
|
bool toggleCollapsed(std::size_t i);
|
|
|
|
// Visible-model access — main thread only (no lock; the deque is main-thread-owned).
|
|
const std::deque<ConsoleModelLine>& lines() const { return lines_; }
|
|
std::size_t size() const { return lines_.size(); }
|
|
bool empty() const { return lines_.empty(); }
|
|
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
|
|
const ConsoleModelLine& back() const { return lines_.back(); }
|
|
|
|
// Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear,
|
|
// fold toggle). The view memoizes its per-frame filter + text-layout passes against this.
|
|
std::uint64_t revision() const { return revision_; }
|
|
|
|
// Absolute sequence number of lines_.front(): +1 per line evicted from the front by the cap, +size() on
|
|
// clear(). Line text never changes after ingest, so a view cache keyed by sequence number realigns with
|
|
// one subtraction (eviction becomes an offset, not a rebuild). Never decreases.
|
|
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::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()
|
|
};
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|