Each piece-table edit costs time linear in the buffer (piece lookup,
line-index update), so operations issuing one edit per match, line or
repetition were quadratic:
- Plain and regex replace-all build the new text in one pass and apply
it as one delete + one insert (one undo group, one chunked journal
record). 12 MB with 229k matches: minutes -> 0.14 s.
- Yank inserts the whole kill in one edit (it split and re-trimmed line
by line): 200k lines in 0.02 s.
- InsertText with a repeat count inserts the repeated text once; the
total is capped at 256 MiB (also across visual-line broadcast).
- Incremental regex search skips lines over 20,000 bytes and renderer
match highlighting over 10,000 (the previous change raised the render
limit to 1 MB): std::regex can be quadratic in line length even for
ordinary patterns such as '.*q', uninterruptibly, and these run on
every keystroke or frame. Explicit regex replace still processes all.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- The writer's periodic fsync now holds a separate io_mtx_, taken also
(before mtx_) by the main-thread paths that close journal fds, instead
of mtx_. Every keystroke takes mtx_ to enqueue its record, so a slow
fsync (writeback pressure, NFS) froze typing.
- A journal's base identity (stat + content CRC) is computed before
taking mtx_ and applied under it; the CRC is skipped above 64 MiB
(size and mtime still identify the file). It re-read the whole file
under the lock on every open and save.
- A buffer that was never journaled (a virtual buffer saved under a
real name) is attached by NotifyFilenameChanged.
- ThreadSanitizer: open_ctx (writer thread) no longer writes ctx.path,
and locked_out is recorded by the caller under mtx_; both raced with
main-thread reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- OpenFromFile builds the new content in a separate PieceTable and
swaps it in only when complete. A reload that ran out of memory used
to leave the buffer empty (filename, journal and dirty state intact),
and the next save truncated the file.
- Editor::ProcessPendingOpens catches exceptions from opening (it runs
in every frontend's loop, outside command dispatch, so bad_alloc on a
huge file ended the editor); the terminal draw is guarded too.
- The hard-link save copy uses a unique mkstemp name, so a leftover
copy no longer blocks every later save; if no copy can be created the
file is still written in place.
- When no temp file can be created in the directory (file writable,
directory not), an existing regular file is written in place instead
of the save failing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Redoing an insert and undoing a delete put the cursor at col + size
on the start row even when the text spans lines, i.e. past the end of
the line. Later edits and their undo records then went to the wrong
place (a join recorded at column 2 of 'a' re-split the text wrongly)
and delete-word-prev read past the line (heap-buffer-overflow under
ASan). The cursor now goes after the text's last newline, and undo and
redo clamp it to the buffer.
- A recovered buffer stays modified in every undo state
(mark_base_unsaved): one edit and its undo made it look clean, and
closing it then discarded the recovered text and its journal.
- Save-and-quit records the saved undo state.
- Visual-line selections are clamped to the buffer when read; the
visual newline counts the cursor's own line only if it was selected.
A stale selection put the cursor rows past the end, where typed text
could not be undone.
- The yank size cap accounts for visual-line yanks (once per line).
- After an exception escapes a command, open undo groups are closed so
later edits do not join them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- 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
- Ownership is applied before permissions: chown clears setuid/setgid,
so saving a 06755 file left it 0755.
- The umask is read once during static initialisation. Reading it at
save time needed umask(0)/umask(old), briefly changing it for the swap
writer thread creating files.
- Before rewriting a hard-linked file in place, the new content is
written and synced to a copy beside it; if the in-place write fails
(ENOSPC, EIO) the copy is kept and named in the error.
- Journal headers also record a CRC-32 of the base file (flags bit 1,
bytes 40..43). A changed mtime with the same size and content
(touch, checkout back and forth) no longer marks a journal stale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Quit checks every buffer for unsaved changes and names them; it used
to look only at the current buffer and exit, dropping the rest (an
unnamed buffer's text was unrecoverable). Save-and-quit does not quit
while other buffers are dirty.
- Reload of a dirty buffer needs a second C-k l with no edit in between
(it discarded edits, undo history and the journal at once), and a file
deleted on disk is not 'reloaded' into an empty buffer.
- At the destructive y/n prompts ("Recover swap edits?" and "Save
changes to X?"), only an explicit 'n' discards; Enter or any other key
cancels. Keys typed while the startup recovery prompt appeared used to
delete the crash journal. SwapRecoveryPrompt_Discard now answers 'n'.
- Saving from the close prompt refuses to overwrite a file changed on
disk, like save.
- Save-as onto a file open in another buffer is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- A session never unlinks a journal it does not own: Detach, ResetJournal
and NotifyFilenameChanged skip removal when this session was locked
out or another session holds the lock, and PruneSwapDir skips held
journals. A second session used to delete the first session's live
journal on save or close.
- The writer thread's periodic fsync now runs under mtx_. It fsynced fds
that the main thread could close concurrently (or that had been
reused); an in-flight fsync also kept a just-closed journal's flock
alive, so the owner's own cleanup saw its journal as held.
- Compaction locks the compacted file before renaming it into place and
keeps using it, so the journal is never unlocked in between; a failed
lock on the truncating reopen marks the session locked out.
- Unnamed-buffer journals include the pid, so sessions no longer share
(and delete) each other's unnamed-N.swp.
- RowsView keeps only the previous generation of cached rows: keeping
all of them made C-u 2000 M-d on a 1 MiB line use ~2 GB.
- Renderers run regex match highlighting through ForEachRegexMatch,
which uses the large-stack worker for lines over 512 bytes; some
patterns overflowed the main stack well under the previous 10,000
byte limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- OpenFromFile opens non-blocking, fstat()s and accepts regular files
only. A directory made tellg() report a huge size and bad_alloc exit
the editor (reachable by Tab-completing a directory in the open
prompt); a FIFO blocked open() forever with SIGINT ignored; a device
node opened as an empty, file-backed buffer that saving then replaced
with a regular file. Files are read to EOF in chunks; a file too large
to load is reported, not fatal.
- Saving refuses to replace an existing non-regular file.
- Exceptions escaping a command handler are reported in the status line
and error log instead of propagating to main() and exiting, which lost
every unsaved buffer (unnamed buffers' journals are never recovered).
- Commands use non-throwing filesystem queries for existence checks,
the current directory and Tab completion (ENAMETOOLONG, EACCES or a
deleted working directory used to throw).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- C0 controls and DEL were passed to ncurses, which acted on them: a
carriage return moved to column 0 and the line padding then erased
the text, so every line of a CRLF file drew blank, and a backspace
ate the previous character. They are now drawn in caret notation
(^M, ^H, ^?) and counted as two cells. C1 controls (and stray bytes
0x80-0x9F, which decode to them; U+009B is CSI) are drawn as '?'.
- The width rule lives in TermWidth.h and is shared by the renderer's
drawing loop, its cursor placement and the command layer's
horizontal scrolling and mouse mapping, which also disagreed on NUL
and invalid bytes.
- GUI config: columns/rows must be 1..1000 and the font size a finite
4..200 (TOML 'size = inf' and huge values were accepted, leading to
UB in float-to-int conversion and int overflow downstream).
Verified in tmux: a CRLF file now shows 'first line^M' where the
baseline showed a blank line.
Audit findings B9 (and the CR/BS case from the follow-up review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- fsync is never retried. After a writeback error Linux may mark the
pages clean, so a retried fsync could succeed although the data was
lost, and the rename then replaced a good file with a bad one.
- New files get 0666 & ~umask instead of mkstemp's 0600. Replaced files
keep their mode (file-type bits masked) and, best effort, their owner
and group.
- Saving through a symlink writes the link's target instead of
replacing the link with a regular file (SaveAs did not resolve it).
- A file with several hard links is rewritten in place, so the other
names see the new content (as vim does by default).
- Save-as (prompt and ':save-as') asks before replacing any existing
file other than the buffer's own; it used to ask only for unnamed
buffers. Save-and-quit refuses to overwrite a file changed on disk,
like save. The first save of a new file records the saved undo state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
libstdc++'s std::regex matcher recurses about once per matched
character, so '.*', 'a+' or '(a|b)*' on a line of a few tens of
thousands of characters (minified JS/JSON) overflowed the 8 MiB stack
and crashed the editor from the regex search prompt or regex replace.
Regex search and regex replace now run their matching on a worker
thread with a 1 GiB reserved stack (RunWithLargeStack; the caller
waits, so nothing is shared concurrently). Renderers skip regex match
highlighting on lines over 10,000 bytes. Catastrophic backtracking
(e.g. (a*)*b) is still possible: std::regex has no time limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Motion and delete commands read rows through RowsView (on-demand,
version-cached) instead of Buffer::Rows(), which rebuilt every line of
the file after each edit. On a 500k-line buffer after an edit:
backspace/delete/motions 80-90 ms -> ~0.1 ms; C-u 3000 C-d 273 s ->
0.25 s. Whole-buffer search scans keep Rows() (cached between edits).
- While a prompt is open only prompt-aware and view-only commands run.
Others edited the buffer behind the prompt (bypassing the read-only
check) or switched/closed buffers under a pending confirmation, which
then acted on the wrong buffer. The ':' command path now applies the
read-only check too.
- Pasting into a prompt (ImGui) flattens line breaks to spaces instead
of emitting Newline, which accepted the prompt and sent the rest of
the paste to the buffer (or ran replace-all).
- The mark region clamps both ends; a stale mark past the end of the
buffer is treated as end of buffer. This fixes kill-region deleting
the wrong text and leaving the cursor past end-of-line.
- Kill-line with a count stops at end of buffer instead of killing
lines above the cursor (regression from the D3 change).
- Undo groups nest, so SmartNewline in visual-line mode is one step.
- Word motions and word deletion treat non-ASCII bytes as word
characters and so never split a UTF-8 character.
- The universal-argument count saturates at 1,000,000 instead of
overflowing int.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
Consolidation rewrites pieces without changing content or offsets but
invalidated the line index, so once a buffer passed the piece limit
every edit paid a full rescan again. A moved-from table kept the index
of the text it gave away, and Insert now updates that index in place,
producing ranges past the end of its buffer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
Every edit bumped the buffer version (through SetDirty) and called
InvalidateFrom(0), so all cached spans and line states were discarded
and stateful highlighters re-lexed from row 0 to the viewport on each
keystroke.
Buffer mutators now report the row they changed (edited_at_), which
bumps the version and tells the engine to drop only rows from there
down; a row's highlighting depends only on rows above it. A version
change that arrives without that notice still clears everything, so a
missed path cannot show stale spans. The engine keeps states as a
contiguous vector and caches spans for every row it lexes. SetDirty no
longer touches the version: the edits themselves do, including undo
and redo back to the saved state (D9 still holds; its test now checks
the highlighting itself).
Typing near the end of a 100k-line C++ file: 46.3 ms -> 0.14 ms of
highlighting work per keystroke.
Audit finding P3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- PieceTable::Insert/Delete adjust a valid line index directly (shift
later line starts, add or drop starts for inserted or deleted
newlines) instead of invalidating it, which forced a byte-by-byte
rescan of the whole buffer on the next line query. On a 38 MB,
1M-line buffer, insert + line query drops from 14.7 ms to 0.28 ms.
- The terminal and ImGui renderers fetch only the rows they draw
(Nrows + GetLineString) instead of Buffer::Rows(), which rebuilt a
string for every line of the file on the first frame after each edit.
Qt's line-count uses switch to Nrows().
ImGuiRenderer.cc was syntax-checked with g++ and clang++ against the
vendored ImGui headers; the GUI targets could not be built here (no
SDL2 or Qt6).
Audit findings P1, P2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Backspace and delete-char remove a whole UTF-8 character (they
removed one byte, leaving an invalid lead byte in the file), and
record the full character for undo.
- Left/right step over whole characters; up/down snap the column to a
character boundary and clamp the row, which also removes an unsigned
underflow in move-down when the cursor was past the last row.
- Horizontal scroll offsets are computed in display cells, the way the
terminal renderer draws (tabs, wide characters), instead of bytes, so
long non-ASCII lines no longer scroll to blank with the cursor off
screen. The mouse column mapping uses the same measure.
Audit findings B5, B6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Regex replace-all, reflow and kill-region replaced or deleted whole
rows, which added or left a stray newline when the text ended on a
last line without one. They now edit the byte range in place.
- Regex replace-all no longer matches the empty position after a final
newline. Before, a zero-width pattern such as ^ matched it, added a
line, and matched the new empty last row again: the editor hung.
- Kill-line on the last row kills an unterminated line with the newline
before it, and does nothing on the empty row after a final newline
(it used to record an undo step that added a line).
- The visual-line newline records its splits as one undo step, and
places the cursor correctly for a repeat count.
- Buffer::delete_text computes its byte range directly instead of
materializing every line it spans.
Audit findings D3, D4, D5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
A secondary GUI window shared the primary's buffer vector but attached
buffers through its own SwapManager. Closing the window destroyed that
manager while buffers still held its recorders, so the next edit called
into freed memory; Rehome through the wrong manager also dropped
journaling for the primary's buffers.
Audit finding M3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB