perf(console): tail-cap large RPC results without a full copy
A large RPC result was fully split into lines and copied before being handed to the console, even though only the last few lines could survive the model's line cap. - The formatter now takes a line cap and keeps only the last N lines, located by a backward newline scan, so a huge result is never fully split or copied. JSON classification still keys off the first character of the whole result. - It works over a string view and moves line text through, and fold spans are computed over the formatted lines directly instead of a separate vector of copies. - getline semantics are preserved: a trailing newline adds no empty line and an empty result yields no lines. Tests: getline parity for empty, trailing-newline and blank-line inputs, the backward tail cap, and JSON classification and fold spans over a capped result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -99,10 +99,10 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
|
||||
is_error = true;
|
||||
}
|
||||
scrubParamStrings(params);
|
||||
return [this, result_str, is_error]() {
|
||||
return [this, result_str = std::move(result_str), is_error]() mutable {
|
||||
if (app_) app_->noteWalletMutation();
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({result_str, is_error});
|
||||
results_.push_back({std::move(result_str), is_error});
|
||||
};
|
||||
});
|
||||
if (refused) why = "A key import or rescan is still running. Try again when it finishes.";
|
||||
@@ -142,7 +142,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
|
||||
{
|
||||
// Publish the result before the gate drops, so a non-busy console always has it queued.
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({result_str, is_error});
|
||||
results_.push_back({std::move(result_str), is_error});
|
||||
}
|
||||
return [this]() {
|
||||
// A Console command may have changed the wallet (a send, an import, a new address): the
|
||||
@@ -162,7 +162,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
|
||||
}
|
||||
if (app_) app_->noteWalletMutation();
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({result_str, is_error});
|
||||
results_.push_back({std::move(result_str), is_error});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -229,32 +229,53 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
|
||||
return call;
|
||||
}
|
||||
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError)
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(std::string_view result,
|
||||
bool isError,
|
||||
std::size_t maxLines)
|
||||
{
|
||||
if (isError) {
|
||||
return {{"Error: " + result, ConsoleResultLineRole::Error}};
|
||||
std::string text;
|
||||
text.reserve(7 + result.size());
|
||||
text.append("Error: ").append(result);
|
||||
return {{std::move(text), ConsoleResultLineRole::Error}};
|
||||
}
|
||||
|
||||
std::vector<ConsoleResultLine> lines;
|
||||
std::string normalized = (result == "null") ? "(no result)" : result;
|
||||
bool isJson = !normalized.empty() && (normalized[0] == '{' || normalized[0] == '[');
|
||||
std::istringstream stream(normalized);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
const std::string_view normalized = (result == "null") ? std::string_view("(no result)") : result;
|
||||
if (normalized.empty()) return lines; // getline yields no lines for an empty string
|
||||
|
||||
const bool isJson = normalized[0] == '{' || normalized[0] == '[';
|
||||
|
||||
// getline parity: split on '\n'; one trailing '\n' terminates the last line (no empty extra).
|
||||
std::size_t end = normalized.size();
|
||||
if (normalized[end - 1] == '\n') --end;
|
||||
|
||||
// Tail cap: walk back until maxLines lines are covered; start just after the boundary newline.
|
||||
std::size_t begin = 0;
|
||||
if (maxLines > 0) {
|
||||
std::size_t seen = 0;
|
||||
for (std::size_t i = end; i-- > 0; ) {
|
||||
if (normalized[i] == '\n' && ++seen == maxLines) { begin = i + 1; break; }
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t pos = begin;
|
||||
while (true) {
|
||||
std::size_t nl = normalized.find('\n', pos);
|
||||
if (nl == std::string_view::npos || nl > end) nl = end;
|
||||
const std::string_view line = normalized.substr(pos, nl - pos);
|
||||
|
||||
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
|
||||
if (isJson && !line.empty()) {
|
||||
std::string trimmed = line;
|
||||
std::size_t first = trimmed.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) trimmed = trimmed.substr(first);
|
||||
if (!trimmed.empty()) {
|
||||
std::size_t first = line.find_first_not_of(" \t");
|
||||
if (first != std::string_view::npos) {
|
||||
const std::string_view trimmed = line.substr(first);
|
||||
unsigned char firstChar = static_cast<unsigned char>(trimmed[0]);
|
||||
if (trimmed[0] == '{' || trimmed[0] == '}' ||
|
||||
trimmed[0] == '[' || trimmed[0] == ']') {
|
||||
role = ConsoleResultLineRole::JsonBrace;
|
||||
} else if (trimmed[0] == '"') {
|
||||
if (trimmed.find("\": ") != std::string::npos ||
|
||||
trimmed.find("\":") != std::string::npos) {
|
||||
if (trimmed.find("\":") != std::string_view::npos) {
|
||||
role = ConsoleResultLineRole::JsonKey;
|
||||
} else {
|
||||
role = ConsoleResultLineRole::JsonString;
|
||||
@@ -268,12 +289,17 @@ std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& re
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push_back({line, role});
|
||||
lines.push_back({std::string(line), role});
|
||||
if (nl >= end) break;
|
||||
pos = nl + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
|
||||
namespace {
|
||||
|
||||
template <typename Lines, typename TextOf>
|
||||
std::vector<int> computeFoldSpans(const Lines& lines, TextOf textOf)
|
||||
{
|
||||
std::vector<int> spans(lines.size(), 0);
|
||||
|
||||
@@ -294,14 +320,16 @@ std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i < lines.size(); ++i) {
|
||||
const char last = lastNonSpace(lines[i]);
|
||||
const std::string& li = textOf(lines[i]);
|
||||
const char last = lastNonSpace(li);
|
||||
if (last != '{' && last != '[') continue; // not a block opener
|
||||
const std::size_t indent = leadingSpaces(lines[i]);
|
||||
const std::size_t indent = leadingSpaces(li);
|
||||
const char wantClose = (last == '{') ? '}' : ']';
|
||||
// Matching closer = the next line at the same indentation whose first non-space char
|
||||
// is the corresponding closing bracket (pretty-printed JSON has aligned brackets).
|
||||
for (std::size_t j = i + 1; j < lines.size(); ++j) {
|
||||
if (leadingSpaces(lines[j]) == indent && firstNonSpace(lines[j]) == wantClose) {
|
||||
const std::string& lj = textOf(lines[j]);
|
||||
if (leadingSpaces(lj) == indent && firstNonSpace(lj) == wantClose) {
|
||||
if (j - i >= 2) spans[i] = static_cast<int>(j - i); // >=1 inner line
|
||||
break;
|
||||
}
|
||||
@@ -310,5 +338,17 @@ std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
|
||||
return spans;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
|
||||
{
|
||||
return computeFoldSpans(lines, [](const std::string& s) -> const std::string& { return s; });
|
||||
}
|
||||
|
||||
std::vector<int> ComputeConsoleResultFoldSpans(const std::vector<ConsoleResultLine>& lines)
|
||||
{
|
||||
return computeFoldSpans(lines, [](const ConsoleResultLine& l) -> const std::string& { return l.text; });
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstddef>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -55,8 +56,13 @@ struct ConsoleArg {
|
||||
std::vector<ConsoleArg> ParseConsoleCommandArgsTagged(const std::string& command);
|
||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command);
|
||||
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError);
|
||||
// Splits an RPC result into classified lines (std::getline semantics: a trailing '\n' adds no
|
||||
// empty line, an empty result yields none). `maxLines` > 0 keeps only the LAST maxLines lines,
|
||||
// found by a backward newline scan so a huge result is never fully split/copied. The JSON
|
||||
// classification still keys off the first char of the whole result. 0 = no cap.
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(std::string_view result,
|
||||
bool isError,
|
||||
std::size_t maxLines = 0);
|
||||
|
||||
// For a block of pretty-printed JSON lines, return a per-line fold span: for a line that
|
||||
// opens a collapsible block (its last non-space char is '{' or '[') the value is the offset
|
||||
@@ -64,6 +70,8 @@ std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& re
|
||||
// for empty/one-line blocks not worth folding (span < 2). Pure; used to drive console
|
||||
// JSON folding.
|
||||
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines);
|
||||
// Same as ComputeConsoleFoldSpans, over formatted result lines directly (no text copies).
|
||||
std::vector<int> ComputeConsoleResultFoldSpans(const std::vector<ConsoleResultLine>& lines);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -2052,15 +2052,15 @@ void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec)
|
||||
|
||||
void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
|
||||
{
|
||||
const std::vector<ConsoleResultLine> resultLines = FormatConsoleRpcResultLines(result, is_error);
|
||||
// Only the last maxLines lines can survive the model's cap, so a huge result is tail-capped
|
||||
// up front instead of being fully split and copied.
|
||||
std::vector<ConsoleResultLine> resultLines =
|
||||
FormatConsoleRpcResultLines(result, is_error, model_.maxLines());
|
||||
|
||||
// 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). The block is ingested under one lock (ingestBatch).
|
||||
std::vector<std::string> texts;
|
||||
texts.reserve(resultLines.size());
|
||||
for (const auto& rl : resultLines) texts.push_back(rl.text);
|
||||
const std::vector<int> foldSpans = ComputeConsoleFoldSpans(texts);
|
||||
const std::vector<int> foldSpans = ComputeConsoleResultFoldSpans(resultLines);
|
||||
|
||||
std::vector<ConsoleModelLine> block;
|
||||
block.reserve(resultLines.size());
|
||||
@@ -2075,7 +2075,7 @@ void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
|
||||
case ConsoleResultLineRole::Result: break;
|
||||
}
|
||||
ConsoleModelLine ml;
|
||||
ml.text = resultLines[i].text;
|
||||
ml.text = std::move(resultLines[i].text);
|
||||
ml.channel = channel;
|
||||
ml.foldSpan = foldSpans[i];
|
||||
block.push_back(std::move(ml));
|
||||
|
||||
Reference in New Issue
Block a user