Terminal loop, virtual buffers, yank bound

- kte refuses to start when stdin is not a terminal: piped bytes were
  executed as editor commands (C-k s plus a path writes a file), and
  after EOF the loop spun at full CPU with no way to quit.
- The terminal frontend handles all pending input before drawing (a
  paste used to cost a frame and a full redraw per character) and
  redraws only after input, a resize or deferred opens, else once a
  second. The renderer caches, per row and buffer version, where the
  horizontally scrolled part of a line starts and the cursor's render
  column, instead of rescanning the line every frame. Idle CPU with the
  cursor at the end of a 20 MB line: one full core -> ~1%.
- Buffers with a virtual name (+HELP+) are not journaled and saving one
  asks for a file name; it used to write ./+HELP+ and journal under
  that path.
- Yank with a repeat count is capped at 256 MiB in total instead of
  reserving count x size bytes (C-u 1000000 C-y of a large kill).
- Remove an overload clang reports as unused (-Werror on macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent 1005f5dbe8
commit 61a7338f88
10 changed files with 184 additions and 18 deletions
+7
View File
@@ -322,6 +322,7 @@ Buffer::Buffer(const Buffer &other)
rows_cache_dirty_ = other.rows_cache_dirty_; rows_cache_dirty_ = other.rows_cache_dirty_;
filename_ = other.filename_; filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
is_virtual_ = other.is_virtual_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
@@ -375,6 +376,7 @@ Buffer::operator=(const Buffer &other)
rows_cache_dirty_ = other.rows_cache_dirty_; rows_cache_dirty_ = other.rows_cache_dirty_;
filename_ = other.filename_; filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
is_virtual_ = other.is_virtual_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
@@ -420,6 +422,7 @@ Buffer::Buffer(Buffer &&other) noexcept
rows_(std::move(other.rows_)), rows_(std::move(other.rows_)),
filename_(std::move(other.filename_)), filename_(std::move(other.filename_)),
is_file_backed_(other.is_file_backed_), is_file_backed_(other.is_file_backed_),
is_virtual_(other.is_virtual_),
dirty_(other.dirty_), dirty_(other.dirty_),
read_only_(other.read_only_), read_only_(other.read_only_),
mark_set_(other.mark_set_), mark_set_(other.mark_set_),
@@ -470,6 +473,7 @@ Buffer::operator=(Buffer &&other) noexcept
rows_ = std::move(other.rows_); rows_ = std::move(other.rows_);
filename_ = std::move(other.filename_); filename_ = std::move(other.filename_);
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
is_virtual_ = other.is_virtual_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
@@ -548,6 +552,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
nrows_ = 0; nrows_ = 0;
filename_ = norm; filename_ = norm;
is_file_backed_ = false; is_file_backed_ = false;
is_virtual_ = false;
dirty_ = false; dirty_ = false;
// Reset cursor/viewport state // Reset cursor/viewport state
@@ -620,6 +625,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
nrows_ = 0; // not used under PieceTable nrows_ = 0; // not used under PieceTable
filename_ = norm; filename_ = norm;
is_file_backed_ = true; is_file_backed_ = true;
is_virtual_ = false;
dirty_ = false; dirty_ = false;
RefreshOnDiskIdentity(); RefreshOnDiskIdentity();
@@ -702,6 +708,7 @@ Buffer::SaveAs(const std::string &path, std::string &err)
filename_ = out_path; filename_ = out_path;
is_file_backed_ = true; is_file_backed_ = true;
is_virtual_ = false;
dirty_ = false; dirty_ = false;
RefreshOnDiskIdentity(); RefreshOnDiskIdentity();
return true; return true;
+10
View File
@@ -334,6 +334,7 @@ public:
{ {
filename_ = name; filename_ = name;
is_file_backed_ = false; is_file_backed_ = false;
is_virtual_ = true;
} }
@@ -343,6 +344,14 @@ public:
} }
// A virtual buffer (e.g. "+HELP+") has a display name that is not a path:
// it is never journaled, and saving it asks for a file name.
[[nodiscard]] bool IsVirtual() const
{
return is_virtual_;
}
[[nodiscard]] bool Dirty() const [[nodiscard]] bool Dirty() const
{ {
return dirty_; return dirty_;
@@ -677,6 +686,7 @@ private:
std::string filename_; std::string filename_;
bool is_file_backed_ = false; bool is_file_backed_ = false;
bool is_virtual_ = false;
bool dirty_ = false; bool dirty_ = false;
bool read_only_ = false; bool read_only_ = false;
bool mark_set_ = false; bool mark_set_ = false;
+12 -10
View File
@@ -108,13 +108,6 @@ utf8_next_len(std::string_view line, std::size_t x)
} }
static inline std::string_view
line_view(const Buffer::Line &l)
{
return std::string_view(l.Data(), l.Size());
}
static inline std::string_view static inline std::string_view
line_view(const std::string &l) line_view(const std::string &l)
{ {
@@ -937,7 +930,8 @@ cmd_save(CommandContext &ctx)
// Allow saving directly to a filename if buffer was opened with a // Allow saving directly to a filename if buffer was opened with a
// non-existent path (not yet file-backed but has a filename). // non-existent path (not yet file-backed but has a filename).
if (!buf->IsFileBacked()) { if (!buf->IsFileBacked()) {
if (!buf->Filename().empty()) { // A virtual buffer's name (e.g. +HELP+) is not a path: ask for one.
if (!buf->Filename().empty() && !buf->IsVirtual()) {
// If first-time save to an existing path, confirm overwrite // If first-time save to an existing path, confirm overwrite
if (fs_exists(buf->Filename())) { if (fs_exists(buf->Filename())) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", ""); ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
@@ -1130,7 +1124,7 @@ cmd_save_and_quit(CommandContext &ctx)
ctx.editor.SetStatus(err); ctx.editor.SetStatus(err);
return false; return false;
} }
} else if (!buf->Filename().empty()) { } else if (!buf->Filename().empty() && !buf->IsVirtual()) {
if (buf->SaveAs(buf->Filename(), err)) { if (buf->SaveAs(buf->Filename(), err)) {
buf->SetDirty(false); buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) if (auto *sm = ctx.editor.Swap())
@@ -3162,7 +3156,7 @@ cmd_newline(CommandContext &ctx)
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->mark_saved(); u->mark_saved();
} }
} else if (!buf->Filename().empty()) { } else if (!buf->Filename().empty() && !buf->IsVirtual()) {
if (!buf->SaveAs(buf->Filename(), err)) { if (!buf->SaveAs(buf->Filename(), err)) {
ctx.editor.SetStatus(err); ctx.editor.SetStatus(err);
proceed_to_close = false; proceed_to_close = false;
@@ -3974,6 +3968,14 @@ cmd_yank(CommandContext &ctx)
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
// Bound the total: C-u 1000000 C-y of a large kill would allocate
// gigabytes (and bad_alloc or the OOM killer end the session).
constexpr std::size_t kMaxYankBytes = std::size_t{256} << 20;
if (text.size() * static_cast<std::size_t>(repeat) > kMaxYankBytes) {
ctx.editor.SetStatus("Yank too large (" + std::to_string(repeat) + " x " + std::to_string(text.size()) +
" bytes)");
return false;
}
std::string ins; std::string ins;
if (repeat == 1) { if (repeat == 1) {
ins = text; ins = text;
+2 -2
View File
@@ -346,8 +346,8 @@ SwapManager::Rehome(Buffer *old_addr, Buffer *new_addr)
void void
SwapManager::Attach(Buffer *buf) SwapManager::Attach(Buffer *buf)
{ {
if (!buf) if (!buf || buf->IsVirtual())
return; return; // e.g. +HELP+: nothing to recover, and its name is not a path
std::lock_guard<std::mutex> lg(mtx_); std::lock_guard<std::mutex> lg(mtx_);
const bool fresh = journals_.find(buf) == journals_.end(); const bool fresh = journals_.find(buf) == journals_.end();
JournalCtx &ctx = journals_[buf]; JournalCtx &ctx = journals_[buf];
+33 -5
View File
@@ -1,3 +1,4 @@
#include <cstdio>
#include <ncurses.h> #include <ncurses.h>
#include <termios.h> #include <termios.h>
#include <unistd.h> #include <unistd.h>
@@ -12,6 +13,12 @@ TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc; (void) argc;
(void) argv; (void) argv;
// Keys are read from stdin. Piped input would be executed as editor
// commands (and after EOF the loop spun at full CPU with no way to quit).
if (!isatty(STDIN_FILENO)) {
std::fprintf(stderr, "kte: standard input is not a terminal\n");
return false;
}
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS) // Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{ {
struct termios tio{}; struct termios tio{};
@@ -88,29 +95,50 @@ TerminalFrontend::Step(Editor &ed, bool &running)
// Handle resize and keep editor dimensions synced // Handle resize and keep editor dimensions synced
int r, c; int r, c;
getmaxyx(stdscr, r, c); getmaxyx(stdscr, r, c);
bool changed = first_frame_;
if (r != prev_r_ || c != prev_c_) { if (r != prev_r_ || c != prev_c_) {
resizeterm(r, c); resizeterm(r, c);
clear(); clear();
prev_r_ = r; prev_r_ = r;
prev_c_ = c; prev_c_ = c;
changed = true;
} }
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c)); ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Allow deferred opens (including swap recovery prompts) to run. // Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens(); if (ed.ProcessPendingOpens())
changed = true;
// Handle all pending input before drawing: one key per frame made a
// pasted block take a frame (and a full redraw) per character. The first
// read waits up to the configured timeout; the rest do not wait.
MappedInput mi; MappedInput mi;
if (input_.Poll(mi)) { int handled = 0;
if (mi.hasCommand) { while (handled < 4096 && input_.PollKey(mi)) {
changed = true;
if (mi.hasCommand)
Execute(ed, mi.id, mi.arg, mi.count); Execute(ed, mi.id, mi.arg, mi.count);
} if (ed.QuitRequested())
break;
if (++handled == 1)
timeout(0);
} }
if (handled > 0)
timeout(16);
if (ed.QuitRequested()) { if (ed.QuitRequested()) {
running = false; running = false;
} }
renderer_.Draw(ed); // Redraw after input or a resize, and otherwise about once a second
// (status messages, deferred work): redrawing every 16 ms kept a core
// busy on large or long-line files while idle.
const auto now = std::chrono::steady_clock::now();
if (changed || now - last_draw_ >= std::chrono::milliseconds(1000)) {
renderer_.Draw(ed);
last_draw_ = now;
first_frame_ = false;
}
} }
+4
View File
@@ -2,6 +2,7 @@
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle * TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
*/ */
#pragma once #pragma once
#include <chrono>
#include <termios.h> #include <termios.h>
#include <signal.h> #include <signal.h>
@@ -32,6 +33,9 @@ private:
TerminalRenderer renderer_{}; TerminalRenderer renderer_{};
int prev_r_ = 0; int prev_r_ = 0;
int prev_c_ = 0; int prev_c_ = 0;
// Redraw only when something may have changed (see Step).
bool first_frame_ = true;
std::chrono::steady_clock::time_point last_draw_{};
// Saved terminal attributes to restore on shutdown // Saved terminal attributes to restore on shutdown
bool have_orig_tio_ = false; bool have_orig_tio_ = false;
struct termios orig_tio_{}; struct termios orig_tio_{};
+9
View File
@@ -20,6 +20,15 @@ public:
bool Poll(MappedInput &out) override; bool Poll(MappedInput &out) override;
// Read one key if available. Returns false when no input was read; true
// when a key was consumed (out.hasCommand says whether it produced a
// command: prefixes such as C-k or ESC do not).
bool PollKey(MappedInput &out)
{
out = {};
return decode_(out);
}
private: private:
bool decode_(MappedInput &out); bool decode_(MappedInput &out);
+44 -1
View File
@@ -241,6 +241,45 @@ TerminalRenderer::Draw(Editor &ed)
return 0; return 0;
} }
}; };
// Fast-forward to the scroll offset with the draw loop's own skip
// rules (a tab is skipped only if it ends at or before coloffs,
// any other character while it starts before coloffs), cached
// for the row's current content.
if (coloffs > 0 && src_i == 0) {
if (skip_cache_.size() < static_cast<std::size_t>(content_rows))
skip_cache_.resize(static_cast<std::size_t>(content_rows));
SkipCache &sc = skip_cache_[static_cast<std::size_t>(r)];
if (!(sc.valid && sc.buf == buf && sc.version == buf->Version() && sc.row == li &&
sc.coloffs == coloffs)) {
std::size_t s = 0, c = 0;
while (s < line.size()) {
std::mbstate_t st = std::mbstate_t();
wchar_t w = 0;
std::size_t n = std::mbrtowc(&w, &line[s], line.size() - s, &st);
if (n == (size_t) -1 || n == (size_t) -2) {
w = static_cast<unsigned char>(line[s]);
n = 1;
} else if (n == 0) {
w = L'\0';
n = 1;
}
if (w == L'\t') {
const std::size_t next_tab = tabw - (c % tabw);
if (c + next_tab > coloffs)
break;
c += next_tab;
} else {
if (c >= coloffs)
break;
c += static_cast<std::size_t>(kte::CellWidth(w));
}
s += n;
}
sc = SkipCache{buf, buf->Version(), li, coloffs, s, c, true};
}
src_i = sc.src;
render_col = sc.col;
}
while (written < cols) { while (written < cols) {
bool from_src = false; bool from_src = false;
wchar_t wch = L' '; wchar_t wch = L' ';
@@ -391,7 +430,10 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t cx = buf->Curx(); std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs()); int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0; std::size_t rx_recomputed = 0;
if (cy < nlines) { if (cy < nlines && cursor_cache_.valid && cursor_cache_.buf == buf &&
cursor_cache_.version == buf->Version() && cursor_cache_.row == cy && cursor_cache_.cx == cx) {
rx_recomputed = cursor_cache_.rx;
} else if (cy < nlines) {
const std::string line_for_cursor = buf->GetLineString(cy); const std::string line_for_cursor = buf->GetLineString(cy);
std::size_t src_i_cur = 0; std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0; std::size_t render_col_cur = 0;
@@ -420,6 +462,7 @@ TerminalRenderer::Draw(Editor &ed)
src_i_cur += len; src_i_cur += len;
} }
rx_recomputed = render_col_cur; rx_recomputed = render_col_cur;
cursor_cache_ = CursorCache{buf, buf->Version(), cy, cx, rx_recomputed, true};
} }
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs()); int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) { if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
+29
View File
@@ -2,6 +2,9 @@
* TerminalRenderer - ncurses-based renderer for terminal mode * TerminalRenderer - ncurses-based renderer for terminal mode
*/ */
#pragma once #pragma once
#include <vector>
#include <cstdint>
#include <cstddef>
#include "Renderer.h" #include "Renderer.h"
@@ -12,4 +15,30 @@ public:
~TerminalRenderer() override; ~TerminalRenderer() override;
void Draw(Editor &ed) override; void Draw(Editor &ed) override;
private:
// Where a row's visible part starts (the draw loop's skip up to the
// horizontal scroll offset), cached per screen row. Scanning a very long
// line from column 0 on every frame made each redraw O(line length).
struct SkipCache {
const void *buf = nullptr;
std::uint64_t version = 0;
std::size_t row = 0;
std::size_t coloffs = 0;
std::size_t src = 0; // byte offset where drawing resumes
std::size_t col = 0; // render column at that offset
bool valid = false;
};
std::vector<SkipCache> skip_cache_;
// Render column of the cursor, cached the same way.
struct CursorCache {
const void *buf = nullptr;
std::uint64_t version = 0;
std::size_t row = 0;
std::size_t cx = 0;
std::size_t rx = 0;
bool valid = false;
} cursor_cache_;
}; };
+34
View File
@@ -9,6 +9,7 @@
#include "ErrorRecovery.h" #include "ErrorRecovery.h"
#include "PieceTable.h" #include "PieceTable.h"
#include "RegexGuard.h" #include "RegexGuard.h"
#include "Swap.h"
#include "UndoTree.h" #include "UndoTree.h"
#include "syntax/HighlighterEngine.h" #include "syntax/HighlighterEngine.h"
#include "syntax/HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
@@ -992,3 +993,36 @@ TEST(Audit_Save_KeepsSpecialModeBits)
ASSERT_EQ(::stat(f.c_str(), &st), 0); ASSERT_EQ(::stat(f.c_str(), &st), 0);
ASSERT_EQ(st.st_mode & 07777, (mode_t) 06755); ASSERT_EQ(st.st_mode & 07777, (mode_t) 06755);
} }
// The help buffer's name is not a path: it is not journaled, and saving it
// asks for a file name instead of writing ./+HELP+.
TEST(Audit_HelpBuffer_NotJournaledOrSavedByName)
{
TestHarness h;
Editor &ed = h.EditorRef();
ASSERT_TRUE(h.Exec(CommandId::ShowHelp));
Buffer *help = ed.CurrentBuffer();
ASSERT_TRUE(help->IsVirtual());
ASSERT_TRUE(h.Exec(CommandId::ToggleReadOnly));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "x"));
ed.Swap()->Flush(help);
ASSERT_TRUE(!std::filesystem::exists(kte::SwapManager::ComputeSwapPathForTests(*help)));
ASSERT_TRUE(h.Exec(CommandId::Save));
ASSERT_TRUE(ed.PromptActive());
ASSERT_TRUE(ed.CurrentPromptKind() == Editor::PromptKind::SaveAs);
ASSERT_TRUE(!std::filesystem::exists("+HELP+"));
ASSERT_TRUE(h.Exec(CommandId::Refresh));
}
// A huge repeated yank is refused rather than allocating without bound.
TEST(Audit_Yank_HugeRepeatRefused)
{
TestHarness h;
Editor &ed = h.EditorRef();
ed.KillRingPush(std::string(1 << 20, 'k'));
ASSERT_TRUE(!h.Exec(CommandId::Yank, std::string(), 1000000));
ASSERT_EQ(h.Buf().Nrows(), (std::size_t) 1);
ASSERT_EQ(h.Line(0), std::string());
}