Skip to content

Commit 6287e57

Browse files
committed
Merge #804: Improve debug log performance
fb87a8b qml: render debug log entries with a virtualized list (johnny9) de76d84 qml: read debug log updates without reloading the file (johnny9) d813155 qml: read debug log only while its page is selected (johnny9) Pull request description: ## Summary - Load and watch the debug log only while its settings page is selected. - Read the bounded log tail on a worker thread and process ordinary appends incrementally instead of resetting every row. - Limit individual physical log lines in the viewer to 1 MiB; oversized lines are skipped until their terminating newline without modifying `debug.log`. - Initially load the newest 1,000 entries and expose older entries in 1,000-row increments through **Load more**. - Replace the fully instantiated output with a virtualized `ListView`. - Preserve the visible row and pixel offset across live prepends, tail pruning, and load-more appends. ## Motivation The previous debug-log view instantiated every displayed row and rebuilt the full output whenever the model reset. A new log write could therefore trigger another full file read, model reset, and delegate rebuild, including while the debug-log page was not selected. This change treats the model and view as one performance path: the model reads only the new bytes and publishes changed rows during normal appends, while the view instantiates only visible delegates and keeps its scroll anchor stable. Follow-up to the performance problem described in #784 and the original viewer in #532. Contributes to #576. Related: #768. Its targeted relative-time role update behavior is already present on `qt6` and remains intact after this refactor; this PR does not close that issue. ## Testing - Built every commit in the branch independently. - Built the final application with the matching non-OpenGL depends toolchain. - Ran Bitcoin commit-message and whitespace checks on every commit. - Ran Ruff 0.15 and `qmllint` on the changed Python and QML files. - Ran the complete C++ unit suite: 540 passed, including `DebugLogModelTests`: 19 passed. - Ran the complete QML suite: 391 passed; the remaining `Send::test_send_uri_import_schedules_fee_estimate` failure (`BitcoinUri is not defined`) reproduces on the PR base. - Ran `test/functional/qml_test_debug_log.py` against the real application. - Verified watcher-driven updates remain capped, offscreen rows are virtualized, and **Load more** adds older rows without moving the viewport. ACKs for top commit: pseudoramdom: ACK fb87a8b Tree-SHA512: 20162c60c882b41935ab6b45cef5a30e881fbad8b33175674c2cad555bcb17ea2293a3c0667fb555084300596853b61ca140cd59e28a8f1630e624662fb9daa7
2 parents c238cef + fb87a8b commit 6287e57

11 files changed

Lines changed: 2185 additions & 419 deletions

qml/components/DebugLogOutputView.qml

Lines changed: 315 additions & 140 deletions
Large diffs are not rendered by default.

qml/models/debuglogmodel.cpp

Lines changed: 603 additions & 170 deletions
Large diffs are not rendered by default.

qml/models/debuglogmodel.h

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <util/fs.h>
99

1010
#include <QAbstractListModel>
11+
#include <QByteArray>
1112
#include <QFileSystemWatcher>
1213
#include <QList>
1314
#include <QString>
@@ -33,15 +34,16 @@ class QThread;
3334
//! Pagination: only the most recent `loadLimit` lines are kept in memory.
3435
//! Call loadMore() to increase the limit by 1000, up to kMaxLoadLimit.
3536
//!
36-
//! File watching: the model connects a QFileSystemWatcher to the log file and
37-
//! coalesces rapid writes with a 500 ms debounce timer before calling refresh().
38-
//! Reads themselves run on a dedicated worker thread so a busy, noisy node cannot
37+
//! File watching: the model only watches the log while active and coalesces
38+
//! rapid writes with a 500 ms debounce timer before calling refresh(). Reads
39+
//! themselves run on a dedicated worker thread so a busy, noisy node cannot
3940
//! stall the UI on every debug-log burst.
4041
class DebugLogModel : public QAbstractListModel
4142
{
4243
Q_OBJECT
4344

4445
Q_PROPERTY(bool hasMoreLines READ hasMoreLines NOTIFY hasMoreLinesChanged)
46+
Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged)
4547
Q_PROPERTY(int loadLimit READ loadLimit WRITE setLoadLimit NOTIFY loadLimitChanged)
4648
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
4749
Q_PROPERTY(QString openError READ openError NOTIFY openErrorChanged)
@@ -68,6 +70,10 @@ class DebugLogModel : public QAbstractListModel
6870
//! Hard ceiling on loadLimit to protect against unbounded memory growth.
6971
static constexpr int kMaxLoadLimit = 50'000;
7072

73+
//! Individual physical log lines larger than this are omitted from the
74+
//! in-app viewer. The debug.log file itself is never modified.
75+
static constexpr qsizetype kMaxLogLineBytes = 1024 * 1024;
76+
7177
explicit DebugLogModel(const fs::path& log_path, QObject* parent = nullptr);
7278
~DebugLogModel() override;
7379

@@ -78,6 +84,9 @@ class DebugLogModel : public QAbstractListModel
7884

7985
bool hasMoreLines() const { return m_has_more_lines; }
8086

87+
bool active() const { return m_active; }
88+
void setActive(bool active);
89+
8190
int loadLimit() const { return m_load_limit; }
8291
void setLoadLimit(int limit);
8392

@@ -94,6 +103,7 @@ class DebugLogModel : public QAbstractListModel
94103

95104
Q_SIGNALS:
96105
void hasMoreLinesChanged();
106+
void activeChanged();
97107
void loadLimitChanged();
98108
void filterChanged();
99109
void openErrorChanged();
@@ -102,25 +112,23 @@ class DebugLogModel : public QAbstractListModel
102112

103113
private:
104114
struct LogLine {
105-
QString lineNumber;
106115
QString content; // HTML-escaped full message text
107116
QString command; // parsed message prefix, plain text
108117
QString message; // parsed message body, plain text
109-
QString identity; // stable identity for incremental refresh matching
118+
qint64 source_offset{-1}; // byte offset in debug.log (stable across appends)
110119
qint64 timestamp_ms; // epoch ms, -1 if not parseable
111120
QString relativeTime; // cached human-readable age
112121
Severity severity{InfoSeverity};
113122

114-
// Identity for change detection in buildDisplayLines(). relativeTime is
115-
// derived (refreshed separately by the relative-time timer) and so is
116-
// deliberately excluded.
123+
// Identity for incremental display diffs. relativeTime is derived
124+
// (refreshed separately by the relative-time timer) and deliberately
125+
// excluded.
117126
bool operator==(const LogLine& o) const
118127
{
119-
return lineNumber == o.lineNumber
128+
return source_offset == o.source_offset
120129
&& content == o.content
121130
&& command == o.command
122131
&& message == o.message
123-
&& identity == o.identity
124132
&& severity == o.severity
125133
&& timestamp_ms == o.timestamp_ms;
126134
}
@@ -132,29 +140,55 @@ class DebugLogModel : public QAbstractListModel
132140
struct ReadResult {
133141
bool file_opened{false};
134142
QString error_message;
135-
QList<LogLine> filtered; // oldest-first, already filtered of blank lines
143+
//! A full snapshot is newest-first and contains at most loadLimit
144+
//! entries. A delta contains only newly completed lines, newest-first.
145+
QList<LogLine> lines;
146+
bool full_snapshot{true};
147+
bool continuity_lost{false};
148+
bool has_more_lines{false};
149+
int snapshot_limit{0};
150+
qint64 file_size{-1};
151+
QByteArray trailing_partial;
152+
//! True after an unfinished line exceeds kMaxLogLineBytes. Subsequent
153+
//! bytes are ignored until its terminating newline restores framing.
154+
bool discarding_oversized_line{false};
155+
QByteArray file_anchor;
136156
};
137157

138158
//! File-reading worker. Pure function — no QObject / signal access —
139159
//! so it can safely run on the dedicated worker thread.
140160
static ReadResult ReadAndFilter(const fs::path& log_path,
141161
int load_limit,
142162
bool full_load,
163+
qint64 previous_file_size,
164+
const QByteArray& previous_partial,
165+
bool previous_discarding_oversized_line,
166+
const QByteArray& previous_anchor,
143167
const std::atomic_bool& cancelled);
144168

145-
//! Raw file read (one pass). Called from ReadAndFilter.
146-
static QList<LogLine> ReadRawLines(const fs::path& log_path,
147-
int max_lines,
148-
const std::atomic_bool& cancelled);
169+
//! Read a bounded tail snapshot by scanning backward in fixed-size blocks.
170+
static ReadResult ReadTail(const fs::path& log_path,
171+
int load_limit,
172+
const std::atomic_bool& cancelled);
173+
174+
//! Parse complete newline-terminated records, returning newest first.
175+
static QList<LogLine> ParseCompleteLines(const QByteArray& bytes,
176+
qint64 base_offset,
177+
int max_filtered_lines,
178+
const std::atomic_bool& cancelled);
149179

150180
//! Completion handler invoked on the GUI thread after ReadAndFilter
151181
//! returns. Applies the result to m_all_lines and rebuilds the display.
152182
void onReadCompleted(const ReadResult& result,
153-
const QString& prev_top_identity,
154183
bool full_load);
155184

156185
void connectFileWatcher();
157-
void buildDisplayLines();
186+
void watchLogPath();
187+
void buildDisplayLines(bool force_reset = false);
188+
void applyLines(QList<LogLine> lines, bool force_reset);
189+
bool applyDelta(QList<LogLine> lines);
190+
void applyDisplayLines(QList<LogLine> lines, bool force_reset);
191+
QList<LogLine> filteredLines(const QList<LogLine>& lines) const;
158192
QString relativeTimeLabel(qint64 timestamp_ms, qint64 now_ms) const;
159193

160194
static void PopulateParsedFields(LogLine& entry, const QString& raw_message);
@@ -171,8 +205,20 @@ class DebugLogModel : public QAbstractListModel
171205
QString m_filter;
172206
int m_load_limit{1000};
173207
bool m_has_more_lines{false};
208+
//! Tail capacity represented by m_all_lines. Kept separate from rowCount
209+
//! so empty/short logs and interrupted loadMore requests are unambiguous.
210+
int m_loaded_limit{0};
174211
QString m_open_error;
175212

213+
//! End-of-file state from the last successful worker read. Normal
214+
//! refreshes validate the anchor, seek to file_size, and parse only bytes
215+
//! appended since then. A bounded partial final line is carried across
216+
//! reads; an oversized one is discarded through its terminating newline.
217+
qint64 m_file_size{-1};
218+
QByteArray m_trailing_partial;
219+
bool m_discarding_oversized_line{false};
220+
QByteArray m_file_anchor;
221+
176222
QFileSystemWatcher m_watcher;
177223
QTimer m_debounce;
178224
QObject* m_reader{nullptr};
@@ -183,7 +229,9 @@ class DebugLogModel : public QAbstractListModel
183229
bool m_read_in_flight{false};
184230
bool m_refresh_pending{false};
185231
bool m_pending_full_load{false};
232+
bool m_active{false};
186233
bool m_stopping{false};
234+
quint64 m_activation_generation{0};
187235
std::atomic_bool m_read_cancelled{false};
188236
};
189237

qml/pages/node/NodeSettings.qml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,12 @@ Page {
280280
sourceComponent: NetworkTraffic { showBackButton: false; showHeader: false }
281281
}
282282
MempoolInformationSettings { showBackButton: false }
283-
SettingsDebugLog { showBackButton: false }
283+
Loader {
284+
id: debugLogLoader
285+
objectName: "settingsDebugLogLoader"
286+
active: root.visible && root.currentSection === 8
287+
sourceComponent: SettingsDebugLog { showBackButton: false }
288+
}
284289
PageStack {
285290
id: aboutStack
286291
initialItem: SettingsAbout {

qml/pages/settings/SettingsDebugLog.qml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ Page {
132132
color: Theme.color.neutral9
133133
placeholderTextColor: Theme.color.neutral5
134134
placeholderText: qsTr("Search...")
135+
// The page is unloaded whenever another Settings section is
136+
// selected, while the C++ model intentionally retains its
137+
// filter. Mirror that retained value on re-entry so the field
138+
// and the rows cannot disagree.
139+
text: debugLogModel.filter
135140
verticalAlignment: TextInput.AlignVCenter
136141
selectByMouse: true
137142
Accessible.name: qsTr("Search debug log")
@@ -201,11 +206,13 @@ Page {
201206
topPadding: 10
202207
accessibleName: qsTr("Debug log entries")
203208
autoScrollToBottom: false
204-
// DebugLogModel renders newest-first at the top, so y > 0 means
205-
// "scrolled away from the newest entries" — which is exactly when
206-
// the "N new entries" pill should be offered.
207-
onScrolled: function(y) {
208-
root.userIsScrolled = (y > 0)
209+
// DebugLogModel renders newest-first at the top, so leaving the
210+
// beginning is exactly when the "N new entries" pill applies.
211+
onScrolled: function() {
212+
// A ListView's content origin is not guaranteed to be zero,
213+
// particularly with variable-height rows and incremental model
214+
// changes. Its boundary state is the authoritative answer.
215+
root.userIsScrolled = !logView.atTop
209216
if (logView.atTop && root.pendingNewLines > 0) {
210217
root.pendingNewLines = 0
211218
}
@@ -220,6 +227,7 @@ Page {
220227
Layout.preferredHeight: 36
221228

222229
TextButton {
230+
objectName: "debugLogLoadMoreButton"
223231
anchors.centerIn: parent
224232
text: qsTr("Load more")
225233
textSize: 13
@@ -320,5 +328,6 @@ Page {
320328
}
321329
}
322330

323-
Component.onCompleted: debugLogModel.refresh(true)
331+
Component.onCompleted: debugLogModel.active = true
332+
Component.onDestruction: debugLogModel.active = false
324333
}

0 commit comments

Comments
 (0)