Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
12 KiB
Bug and Performance Audit
Project: kte (Kyle's Text Editor)
Date: 2026-09-23
Auditor: Claude Code (automated review, four parallel reviewers plus
spot verification)
Baseline: d8d2ae8 (debug build clean, kte_tests 0 failures)
Method
The tree was split into four review areas: core text model (PieceTable,
Buffer, OptimizedSearch), undo and swap (UndoSystem/Tree/Node, Swap,
ErrorRecovery, SyscallWrappers), commands (Command.cc, KKeymap), and
frontends/syntax (Terminal, ImGui, Qt, Editor, syntax/). Every finding
below was traced to a concrete scenario. Findings marked [repro] were
reproduced with a small test program; the rest were confirmed by reading
the code.
Finding IDs are stable and are referenced by fix commits:
- M — memory safety (crash, out-of-bounds, use-after-free, data race)
- D — data integrity (undo, swap journal, text corruption)
- B — incorrect behaviour
- P — performance
M — Memory safety
M1. PieceTable assignment keeps a stale line index [repro]
PieceTable.cc:47-98. Copy and move assignment copy pieces_, add_,
and total_size_, but leave the destination's line_index_ and
line_index_dirty_ untouched. Editor::CloseBuffer erases from
std::vector<Buffer>, which move-assigns every later buffer into the
previous slot.
Scenario: open A ("line1\nline2\nline3\n"), open B ("x"), render
both, close A. Slot 0 now holds B's text with A's index: LineCount()
returns 4 and GetLineView(1) builds a view over bytes 6–12 of a 1-byte
buffer.
Fix: mark the line index dirty (and clear it) in both assignment operators.
M2. Background highlighter thread races buffer mutation
syntax/HighlighterEngine.cc:184-249. PrefetchViewport (called from
the terminal and ImGui renderers) queues rows for a worker thread which
calls buf.GetLineString, walking pieces_ and reading add_. The main
thread's PieceTable::Insert/Delete mutate both without any shared
lock; add_.append may reallocate. The worker also stores a raw
Buffer* (pending_.buf) which dangles when AddBuffer's push_back
reallocates or CloseBuffer's erase shifts buffers.
Fix: remove the background warm-up, or feed it an immutable snapshot.
M3. Per-window SwapManager leaves dangling recorders (ImGui multi-window)
ImGuiFrontend.cc:381-385, Editor.cc:205-213. Each window's Editor
owns its own SwapManager but shares the buffer vector. A buffer created
from a secondary window keeps a swap_rec_ pointing into that window's
manager; closing the window destroys it, and the next edit in the main
window calls into freed memory. Rehome from the wrong manager also
returns nullptr, silently disabling journaling for the primary's
buffers.
Fix: share one SwapManager across windows, and detach every buffer an
Editor attached when that Editor is destroyed.
M4. Recursive undo-tree free overflows the stack [repro]
UndoTree.cc:12 (free_node_graph), also UndoSystem.cc:354
(is_descendant, dfs_find_parent). Recursion depth equals history
length. About 300k nodes segfaults a debug build on an 8 MB stack;
replace-all over ~150k matches (two nodes each) followed by close or
reload is enough.
Fix: free iteratively with an explicit stack.
M5. Jump-to-mark places the cursor past the end of the buffer
Command.cc:3720. Marks are not adjusted on edits and
cmd_jump_to_mark does not clamp. After deleting lines below a mark,
C-k j puts the cursor on a nonexistent row; cmd_move_left and
cmd_move_up (:3866, :3988) then index rows[y] out of bounds.
Fix: clamp in cmd_jump_to_mark and in compute_mark_region.
D — Data integrity
D1. Smart-newline undo deletes the wrong text [repro]
Command.cc:3139-3146. The indent is inserted and the cursor moved past
it before u->Begin(UndoType::Insert); UndoSystem::Begin records the
cursor position, so the node is placed indent.size() columns too far
right. " foobar" + SmartNewline + two undos yields
" foo next" (the following line's text is spliced in and bar
is lost).
Fix: Begin at new_x, then insert, then move the cursor; group the
newline and the indent.
D2. Cursor column left past end-of-line corrupts undo
cmd_scroll_up/cmd_scroll_down (Command.cc:4178, :4215, bound to
the mouse wheel), replace-all's cursor restore (:2580), and
cmd_jump_to_mark call SetCursor without clamping x. delete_text
does not clamp; it treats the excess as crossing the newline.
Backspace then joins lines with an empty (dropped) undo node, and
typing followed by undo deletes a newline instead of the typed
character.
Fix: clamp x to the line length in Buffer::SetCursor.
D3. delete_row on a final line without trailing newline leaves a stray \n
Buffer.cc:803. Affects delete_region (C-w, M-d, M-Backspace), regex
replace (Command.cc:2996), reflow (:4884), and the kill ring.
"foo\nbar" + M-d at end of foo yields "foo\n".
D4. Phantom empty row after a trailing newline
PieceTable::LineCount() counts the empty row after a final \n.
C-k C-d on that row records a DeleteRow("") whose undo inserts a
line (Command.cc:3516); regex replace of ^ matches it and appends
an extra line (:2985).
D5. Missing or wrong undo grouping
- Plain replace-all (
Command.cc:2537-2577) has no group: N replacements need 2N undos, and the final Insert stays pending so the next typed characters coalesce into it. - Visual-line newline (
:3043-3075) records no undo at all.
D6. Reflow on a blank line merges the adjacent paragraphs
Command.cc:4592. "aaa\n\nbbb" with the cursor on the blank line and
ESC q gives "aaa bbb".
D7. Swap journal is fragile exactly where it matters
Swap.cc:1344-1373: a torn final record (the power-loss case) makes the whole file unreadable instead of replaying the valid prefix.Swap.cc:1134-1157,:1187,:1224: after any dropped record (breaker open, >16 MB payload, partial write) later records still replay against a different base state; a partial write leaves bytes that later records are appended behind.Swap.cc:1040vs:1187: checkpoints allow 4 GB but the record length is 24 bits; buffers over 16 MB are never checkpointed and the journal grows without bound.Swap.cc:644,:708: a failed header write leaves an fd with no header; answering N to "delete corrupt swap?" appends new edits to the corrupt file.
D8. Circuit breaker failure window never expires
ErrorRecovery.cc:77. last_failure_time_ is updated before
IsWindowExpired() reads it, so elapsed time is always zero; five
failures hours apart still open the breaker.
D9. Stale syntax highlighting after undo-to-clean or reload
Buffer.h:391-400. version_ is bumped only in SetDirty(true). Undo
back to the saved node calls SetDirty(false), and OpenFromFile
(reload) never bumps it, so HighlighterEngine keeps serving spans for
the old text.
B — Behaviour
B1. set_escdelay is compiled out
TerminalFrontend.cc:46. set_escdelay is a function in ncurses, not a
macro, so #ifdef set_escdelay is always false and ESC waits the
1000 ms default.
B2. Terminal resize runs C-g
TerminalInputHandler.cc:167-171 maps KEY_RESIZE to
CommandId::Refresh, the cancel handler: it clears the mark, cancels
prompts (including the swap-recovery prompt, so the file is never
opened), and resets the universal argument.
B3. ESC-as-Meta flag is not cleared on most paths
TerminalInputHandler.cc:56-279. ESC followed by an arrow, Enter, Tab,
or a control chord leaves esc_meta set; the next plain d runs M-d.
Same in ImGuiInputHandler.cc:43-73.
B4. C-h shadows Backspace in the terminal
The generic control lookup (TerminalInputHandler.cc:264) maps ^H to
SearchReplace before the Backspace branch (:307) runs.
B5. Byte-based editing splits UTF-8
Command.cc:3259, :3352, :3861-3947. Backspace, delete, and
horizontal motion step by byte: Backspace on é leaves a lone 0xC3,
and C-b/C-f land inside code points.
B6. Horizontal scroll counts bytes, renderer counts cells
compute_render_x (Command.cc:52-60) counts bytes while
TerminalRenderer.cc:269-322 skips display cells: a long CJK line
scrolls to blank with the cursor off-screen.
B7. GUI draws non-ASCII text as replacement glyphs
Highlighters emit one span per non-ASCII byte and ImGuiRenderer.cc
(~510) / QtFrontend.cc (~333) decode each span separately.
B8. ImGui drops the character after Ctrl+V
ImGuiInputHandler.cc:362, :382, :401 set a suppress-next-text flag
for chords that produce no text event.
B9. Control characters mis-measured in the terminal
TerminalRenderer.cc:314, :364, :413 count C0 controls as width 1;
ncurses draws them as ^X.
B10. Qt tab width disagrees with the command layer
QtFrontend.cc:138 uses 4; Command.cc uses 8.
B11. Smart newline edits the old buffer while a prompt is open
Command.cc:3131. With a prompt active, cmd_newline accepts it and
the saved indent is inserted through a possibly stale Buffer*.
B12. Line count stale after merged appends [repro]
PieceTable.cc:220-260. addPieceBack/addPieceFront merge paths
skip InvalidateLineIndex(). Latent today.
B13. Syntax highlighter edge cases
Go raw strings and Rust multi-line strings do not carry state across
lines; shell $# is highlighted as a comment; a C++ #define line
opening /* does not carry the comment.
P — Performance
P1. Whole-file Rows() rebuild per frame
Renderers call buf->Rows() every frame (TerminalRenderer.cc:45,
ImGuiRenderer.cc:69, QtFrontend.cc:477), and
ensure_cursor_visible (Command.cc:91) after every command. After an
edit this rebuilds a std::string per line, each via two GetRange
calls.
P2. Whole-buffer materialization for one line
Buffer::GetLineView (Buffer.cc:633) calls content_.Data(), which
copies the entire file; the line index is rebuilt byte-by-byte after
every edit.
P3. Re-highlight from row 0 on every keystroke
SetDirty(true) calls InvalidateFrom(0); stateful highlighters then
re-lex from the top of the file to the viewport.
P4. Quadratic region commands
Replace-all, delete_region, indent/unindent, reflow, and repeated
backspace re-fetch Rows() after each mutation inside their loops.
P5. Opening a file checkpoints into a journal that is then deleted
Swap.cc:493 snapshots, writes, and fsyncs the whole file into
unnamed-N.swp before removing it.
P6. Boyer–Moore bad-character shift degenerates to 1
OptimizedSearch.cc:63, :96: when the mismatched byte is absent from
the pattern the shift is 1 instead of j. Test-only today.
Recommended order
- M1 (one-line fix)
- M2 (remove or snapshot the warm-up thread)
- D2 + M5 (clamp in
SetCursorand jump-to-mark) - D1 (smart-newline undo order)
- D7 (accept a torn final swap record)
- P1/P2 (
Nrows()+GetLineStringin hot paths)
Fix status
Updated as fixes land on claude/funny-fermat-gj2vm5.
| ID | Status |
|---|---|
| M1 | fixed: assignment operators invalidate the line index |
| M2 | open: needs a decision (drop the warm-up thread, or snapshot) |
| M3 | open |
| M4 | fixed: iterative free/search; clear() also freed only the first root branch (leak), now frees all |
| M5 | fixed: jump-to-mark clamps the cursor |
| D1 | fixed: Begin before the cursor moves; newline + indent grouped |
| D2 | fixed for scroll up/down and replace-all restore |
| D3 | open |
| D4 | open |
| D5 | replace-all fixed (one undo group); visual-line newline open |
| D6 | fixed: reflow on a blank line is a no-op |
| D7 | open: recovering a torn tail reverses SwapReplay_TruncatedLog_FailsSafely; needs a decision |
| D8 | fixed: window checked before recording the failure |
| D9 | fixed: SetDirty always bumps the version; reload and replace_all_bytes too; reload of a deleted file clears undo |
| B1 | fixed: guard on NCURSES_VERSION |
| B2 | fixed: KEY_RESIZE issues no command |
| B3–B10, B13 | open |
| B11 | fixed: SmartNewline with a prompt open delegates to Newline |
| B12 | fixed: merged appends invalidate the line index |
| P1 | partial: ensure_cursor_visible no longer calls Rows(); renderers still do |
| P2 | partial: ensure_cursor_visible no longer materializes the buffer |
| P3 | open |
| P4 | replace-all fixed; other region commands open |
| P5, P6 | open |
Regression tests: tests/test_audit_regressions.cc. Each test fails on the
unfixed code (M4 by crashing).