Skip to content

BufferSearch: fix the Trimmed race; stateful BufferScan for off-thread search - #183

Merged
tomlm merged 5 commits into
tomlm:mainfrom
confetty-sh:foxery/buffersearch-trim-race
Oct 31, 2026
Merged

tomlm merged 5 commits into
tomlm:mainfrom
confetty-sh:foxery/buffersearch-trim-race

Conversation

@JohnCampionJr

@JohnCampionJr JohnCampionJr commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

The race

A host raises TerminalBuffer.Trimmed on its PTY reader thread. BufferSearch.OnTrimmed compacted the hit list and called RemoveRange on it right there. Meanwhile the UI thread could be stepping through the hits, or drawing from a HitsOnRow span over the list's backing array. So a held span could be rewritten in place mid-draw, and a move could index past a list that was shrinking.

Fix

The buffer now keeps a running count of trimmed lines, TerminalBuffer.TrimmedLines, readable from any thread. BufferSearch no longer subscribes to Trimmed at all. Its reader thread compares the count with the mark its hits were last rebased to. That is one volatile long read per call. It shifts the hits before Count, CurrentIndex, HitsOnRow or the moves look at them.

There's no lock on the per-frame path, and a span can only change on the thread that asked for it.

Because the count lives on the buffer, trims on the alternate screen are now counted even when the search was created on the normal one. Find also searches the screen buffer now, rather than the one-row status buffer while a program writes its status line.

Off-UI-thread search: BufferScan

A host can search in slices, releasing its terminal lock between them.

// XTerm.Search.BufferSearch
public BufferScan BeginScan(string needle, SearchOptions options = default);
public void Apply(BufferScan scan);
public int Find(string needle, SearchOptions options = default);   // unchanged single-call path

// XTerm.Search.BufferScan (sealed; made only by BeginScan)
public BufferScanStatus Next(int maxRows);
public BufferScanStatus Status { get; }
public int Count { get; }
public string Needle { get; }
public SearchOptions Options { get; }

public enum BufferScanStatus { More, Done, Truncated, Invalidated }

// XTerm.Buffer.TerminalBuffer
public long TrimmedLines { get; }
public int ResizeCount { get; }
  • One frame for every hit. The scan owns its hits and its resume position. Every Next first shifts both by the lines trimmed since its last call, then searches the next slice. The caller never holds a row number, so the two can't end up in different frames.
  • The resume position sits just past a whole logical line, so a wrapped line whose head was trimmed to row 0 is searched exactly once.
  • Next reports Invalidated on a switch between the normal and alternate screens, or a resize. The hits are discarded, and the caller begins again. Writing the status line does not invalidate a scan.
  • Threading:
    • BeginScan can be called on any thread.
    • Next runs on any thread holding the terminal lock, one call at a time.
    • Apply runs only on the search's reader thread, after the last Next, and needs no lock: lines trimmed in transit are applied on the next read.
    • Apply throws unless the scan is Done or Truncated, or if it was begun on another search.

Tests

  • Race test: a Barrier-lockstep test where the main thread holds a span while a writer thread trims. It fails on the old code and passes with the fix.
  • Trims between slices: A_trim_between_slices_leaves_exactly_what_a_fresh_Find_finds uses trims of 1, 3, 7 and 25 lines. The same scenario, following the previous head's docs, gave rows that were off, an ArgumentException from the order check, or stale hits.
  • Row 0: A_scan_resumed_on_a_wrapped_line_at_row_0_searches_it_once.
  • Invalidation: screen switch and resize invalidate a scan; the status line doesn't.
  • Late trims: lines trimmed after Done but before Apply are still applied.
  • Refusals: Apply refuses an unfinished scan, or another search's scan.
  • Results: 34 BufferSearchTests pass. The whole XTerm.NET.Tests project: 2351 passed, 1 skipped, 0 failed.
  • Mutation check: five mutants, each caught by its intended test:
    • hits not shifted;
    • resume row not shifted;
    • resume one row early;
    • resize not checked;
    • HitsOnRow skipping the catch-up.

Counting trims adds one store per trim on the scroll path. It isn't benchmarked locally; the CI perf gate's scroll-ascii corpus measures it.

🤖 Generated with Claude Code

…; split Find into Scan + Apply

A host raises TerminalBuffer.Trimmed on its PTY reader thread. OnTrimmed
compacted and RemoveRange'd the hit list there, while the UI thread could be
stepping through it or drawing from a HitsOnRow span over its backing array.
A held span was rewritten in place mid-draw, and a move could index past a
shrinking list.

OnTrimmed now only adds to a monotonic trim counter (Interlocked). The reader
thread compares it with the mark the hits were last rebased to - one volatile
long read per call - and shifts them before Count, CurrentIndex, HitsOnRow
and the moves look at them. No lock on the per-frame path, and a span can
only change on the thread that asked for it.

For an off-UI-thread find (Iciclecreek FindInBufferAsync), Find is split:
- BufferSearch.Scan(buffer, startRow, endRow, needle, options, into): static,
  touches no search state, appends to a caller list with continuing match ids
  so slices concatenate to one scan, returns NextRow / Truncated.
- Apply(hits, truncated[, trimMark]): publishes hits on the reader thread,
  shifting them for lines trimmed since TrimMark was read.
- Find is a scan of every row into the result list plus the same publish step.

Tests: a Barrier-lockstep race test (main thread holds a span while a writer
thread trims; fails on the old code with [1:0+6#0] becoming [0:0+6#0]),
Scan leaves state alone, Scan+Apply matches Find, sliced scans equal one scan
(stepped and fixed boundaries), the cap across slices, Apply with a trim mark,
and Apply rejecting out-of-order hits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@tomlm tomlm left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moves the hit-list rewrite off the thread that raises Trimmed: OnTrimmed now only bumps an Interlocked counter, and the reader thread rebases the hits the next time Count/CurrentIndex/HitsOnRow/the moves read them. The PR also splits Find into a static Scan (sliced, stateless) plus Apply(hits, truncated, trimMark).

The trim-counter design is sound for Find. The sliced Scan path has a coordinate problem, and that path is the reason the split exists.

High

  • Sliced Scan mixes coordinate frames when a trim lands between slices. The host releases the terminal lock between slices, and with a full scrollback trims arrive continuously. Hits from earlier slices stay in pre-trim rows, later slices write post-trim rows, and no single trimMark passed to Apply can be right for both. The highlights land N rows off, or Apply throws on the row-order check. No test covers a trim between slices. (inline on TrimMark and SearchScan.NextRow)

Notes (checked, fine or low impact)

  • CatchUpWithTrims on HitsOnRow is one volatile long read plus a compare. The method is small enough to inline, and Rebase is only called on a mismatch. That fits the hot-path guidance in CLAUDE.md.
  • Rebase folding several trims into one shift is correct: rows are only ever decremented, so a row below zero after the first trim stays below zero. The _current - dropped adjustment is unchanged from main and still right, because dropped hits are always a prefix.
  • Find reads the mark before scanning and publishes with it. Under the terminal lock no trim can land in between, so _rebasedTo is exact.
  • ScanCore's row = runEnd skip gives the same result as the old loop, which reached continuation rows and skipped them on IsWrapped. NextRow = max(runEnd+1, end) handles a run that wraps past endRow. The stepped and fixed-boundary slice tests do exercise this.
  • The race test fails against the old code. It asserts that the held span is unchanged and that the rebased result matches a fresh Find, so it checks the behavior itself rather than the decision to act.
  • Low: the Scan remarks say rows outside the buffer are clamped. With a non-empty needle and startRow > Lines.Length, ScanCore returns NextRow = startRow unclamped. The empty-needle branch returns clamp(endRow) instead, so the two branches disagree on NextRow when startRow > endRow. A start < length loop still terminates, so there is no failure; it is only a doc mismatch.
  • Low: Apply's trimMark > TrimMark check cannot catch a mark taken from a different BufferSearch instance. Each instance's counter starts at 0 when it is constructed.

Comment thread src/XTerm.NET/Search/BufferSearch.cs Outdated
/// </summary>
/// <remarks>
/// The mark that ties hits from <see cref="Scan"/> to a moment in the buffer's life. Read it at
/// the point the scanned rows are correct — under the terminal lock, when the last slice ends —

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: one trim mark cannot describe hits from slices scanned on either side of a trim.

ScanCore/AddRuns write absolute rows as they stand when that slice runs (into.Add(new SearchHit(row, ...))). These docs tell the host to release the lock between slices and to read the mark "when the last slice ends". SearchScan.NextRow tells it to shift only NextRow for trims. Nothing shifts the hits already in into. With a full scrollback and output flowing, which is when an off-thread find matters, trims land between slices routinely.

Trace (scrollback full):

  • Slice 1 [0,50) finds hits at rows 10 and 40.
  • Output trims 5 lines. The host shifts NextRow 50 -> 45, as SearchScan.NextRow says.
  • Slice 2 [45,95) finds the text that was at row 51, now written as row 46.
  • into = [10, 40, 46]. Rows 10 and 40 are pre-trim (correct now: 5 and 35); row 46 is post-trim.
  • Mark read at the end -> Apply shifts nothing. The first two highlights draw 5 rows below their text.
  • Mark read at the start instead -> Apply shifts all three by 5, and row 46 lands on 41, which is wrong the other way.
  • With a 20-line trim, slice 2 starts at 30 and its first hit is row 31, after a 40 already in into. Apply throws ArgumentException from the row-order check and clears the results.

A second effect: if the shifted start clamps to 0, and row 0 is a continuation whose head was trimmed, ScanCore treats it as a run start (row > 0 &&). Text the previous slice already matched is matched again under a new id.

Fix: give the accumulated list a single frame. Two options:

  • Have Scan take the mark the list is currently in and return the mark it scanned at, shifting/dropping into's existing hits (and returning a shifted NextRow) when they differ. Apply then gets one mark that is true for every hit.
  • Expose a static Rebase(List<SearchHit>, long delta) and document that the caller must call it between slices.

Either way, add a test that trims between two slices and compares the result against a Find on the final buffer. The current slice tests never write between slices, so this branch is untested.

@JohnCampionJr JohnCampionJr Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and reproduced. Against af02261, trims of 1, 3, 7 and 25 lines between slices gave rows that were off, an ArgumentException from the order check, and stale hits.

Fixed in 13be8d9 by taking the frame away from the caller:

  • BeginScan returns a stateful BufferScan.
  • Each Next(maxRows), under the terminal lock, first shifts its own accumulated hits and its resume row by the lines trimmed since its last call, then scans.
  • Apply(scan) publishes with the scan's own buffer and mark.
  • The resume row is always just past a whole logical line, so the clamped-to-0 double count can't happen. If the trim passes everything searched, row 0 is new ground and is treated as a run start, as Find does.

The static Scan, SearchScan, TrimMark and Apply(hits, ...) are removed. The trim count now lives on the buffer (TerminalBuffer.TrimmedLines), so BufferSearch doesn't subscribe to Trimmed at all.

Tests:

  • A_trim_between_slices_leaves_exactly_what_a_fresh_Find_finds (1, 3, 7, 25 lines) and A_scan_resumed_on_a_wrapped_line_at_row_0_searches_it_once, both compared against a fresh Find.
  • Mutants that shift only the hits, only the row, or resume one row early are each caught.

Comment thread src/XTerm.NET/Search/SearchTypes.cs Outdated
/// <summary>
/// The first row this scan did not search: the start of the next slice. Past the slice's end row
/// when its last line wrapped beyond it. An absolute buffer row, so shift it down for any lines
/// trimmed before the next slice runs.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High (same root cause as the comment on TrimMark): following this advice leaves the accumulated hits in the old frame.

Shifting NextRow alone moves the next slice into post-trim coordinates while every hit already in into stays pre-trim. The list then holds two frames, and Apply(..., trimMark) can shift it by only one amount. Example: trim 5 between slices -> earlier hits draw 5 rows low. Trim 20 -> slice 2's first row (31) sorts before an earlier hit (40), and Apply throws on the order check. Whatever this doc tells the caller to shift, it has to cover the hits in into as well as NextRow, or Scan should do the shift itself.

@JohnCampionJr JohnCampionJr Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause, same fix in 13be8d9: SearchScan and its NextRow advice are gone. The caller never holds a row any more. BufferScan keeps its position privately and shifts it together with its hits on every Next, so the two can't drift into different frames.

Review on tomlm#183: the static Scan let a host release the terminal lock between
slices while hits from earlier slices stayed in pre-trim rows and later
slices wrote post-trim rows. No single trim mark passed to Apply could be
right for both, so highlights landed N rows off, or a big trim put a new hit
above an old one and Apply threw on the row-order check. Resuming from a
NextRow shifted to 0 could also search the tail of a wrapped line twice.

Replaced with a scan object that owns the frame:

- BufferSearch.BeginScan(needle, options) returns a BufferScan. Nothing is
  read until the first Next.
- BufferScan.Next(maxRows), under the terminal lock on any one thread at a
  time: shifts its own hits AND its resume row by the lines trimmed since
  the last call, then searches the next slice. The resume row is always just
  past a whole logical line, so it never lands inside one already searched.
  Returns More, Done, Truncated or Invalidated. Invalidated means the
  terminal switched screens or the buffer was resized; begin again.
- BufferSearch.Apply(BufferScan) on the reader thread publishes with the
  scan's own buffer and mark; trims while it crossed threads are applied on
  the next read.
- Static Scan, SearchScan, TrimMark and Apply(hits, ...) are gone, which also
  settles the review's two Low notes.

The trim count moved onto the buffer: TerminalBuffer.TrimmedLines is a
running total (one store per trim, readable from any thread), and
ResizeCount counts size changes. BufferSearch no longer subscribes to
Trimmed, so it also tracks trims on the alternate screen, which it missed
before when made on the normal one. Find and scans read the screen buffer
(normal or alternate), so a program writing its status line neither
invalidates a scan nor makes Find search the one-row status buffer.

Tests: trims between slices of 1, 3, 7 and 25 lines equal a fresh Find
(the same scenario against the previous API failed all four: rows off,
ArgumentException, stale hits); trims after Done; a wrapped line's tail at
row 0 searched exactly once, both when already searched and when the trim
passed the resume row; screen switch and resize invalidate; status line
does not; Apply refuses unfinished or foreign scans; slicing and the cap
still match Find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JohnCampionJr added a commit to confetty-sh/XTerm.NET that referenced this pull request Oct 31, 2026
Review on tomlm#183: the static Scan let a host release the terminal lock between
slices while hits from earlier slices stayed in pre-trim rows and later
slices wrote post-trim rows. No single trim mark passed to Apply could be
right for both, so highlights landed N rows off, or a big trim put a new hit
above an old one and Apply threw on the row-order check. Resuming from a
NextRow shifted to 0 could also search the tail of a wrapped line twice.

Replaced with a scan object that owns the frame:

- BufferSearch.BeginScan(needle, options) returns a BufferScan. Nothing is
  read until the first Next.
- BufferScan.Next(maxRows), under the terminal lock on any one thread at a
  time: shifts its own hits AND its resume row by the lines trimmed since
  the last call, then searches the next slice. The resume row is always just
  past a whole logical line, so it never lands inside one already searched.
  Returns More, Done, Truncated or Invalidated. Invalidated means the
  terminal switched screens or the buffer was resized; begin again.
- BufferSearch.Apply(BufferScan) on the reader thread publishes with the
  scan's own buffer and mark; trims while it crossed threads are applied on
  the next read.
- Static Scan, SearchScan, TrimMark and Apply(hits, ...) are gone, which also
  settles the review's two Low notes.

The trim count moved onto the buffer: TerminalBuffer.TrimmedLines is a
running total (one store per trim, readable from any thread), and
ResizeCount counts size changes. BufferSearch no longer subscribes to
Trimmed, so it also tracks trims on the alternate screen, which it missed
before when made on the normal one. Find and scans read the screen buffer
(normal or alternate), so a program writing its status line neither
invalidates a scan nor makes Find search the one-row status buffer.

Tests: trims between slices of 1, 3, 7 and 25 lines equal a fresh Find
(the same scenario against the previous API failed all four: rows off,
ArgumentException, stale hits); trims after Done; a wrapped line's tail at
row 0 searched exactly once, both when already searched and when the trim
passed the resume row; screen switch and resize invalidate; status line
does not; Apply refuses unfinished or foreign scans; slicing and the cap
still match Find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Foxery <noreply@foxery.ai>
@JohnCampionJr JohnCampionJr changed the title BufferSearch: fix the Trimmed race; split Find into Scan + Apply BufferSearch: fix the Trimmed race; stateful BufferScan for off-thread search Oct 31, 2026
@JohnCampionJr

JohnCampionJr commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks. The High finding was right; fixed in 13be8d9 with a stateful BufferScan (details in the inline threads).

Both Low notes are moot now:

  • The static Scan, whose NextRow clamping disagreed between branches, is removed.
  • A mark from another instance can no longer reach Apply: it takes a BufferScan, and throws ArgumentException for one begun on a different search.

Also new:

  • Next returns Invalidated on a screen switch or resize.
  • The trim count lives on the buffer, so trims on the alternate screen are counted even for a search made on the normal one.
  • Find and the scan search the screen buffer, so a program writing its status line doesn't disturb them.

XTerm.NET.Tests: 2351 passed, 1 skipped, 0 failed, and five mutants are each caught. One thing for the perf gate: counting trims adds one store per trim on the scroll path, which scroll-ascii will measure. I've updated the PR description to match.

JohnCampionJr added a commit to confetty-sh/XTerm.NET that referenced this pull request Oct 31, 2026
JohnCampionJr added a commit to confetty-sh/XTerm.NET that referenced this pull request Oct 31, 2026
@tomlm
tomlm merged commit 25cb35a into tomlm:main Oct 31, 2026
4 checks passed
tomlm added a commit to tomlm/Iciclecreek.Avalonia.Terminal that referenced this pull request Nov 1, 2026
…ked slices (#198)

* Add FindInBufferAsync: search the scrollback off the UI thread in locked slices

FindInBuffer ran BufferSearch.Find on the UI thread with no lock while the
pty reader wrote under _terminalLock -- a data race, and ~0.2 us a row
(21 ms at 100k lines, 210 ms at 1M).

FindInBufferAsync (TerminalView, forwarded on TerminalControl and
TerminalWindow) steps an XTerm.NET BufferScan on the thread pool, holding
_terminalLock for one Next(4096) at a time and releasing it between slices,
so the reader waits at most one slice and the UI thread never waits. An
Invalidated scan (screen switch or resize) is begun again; the scan rebases
its own hits for trims. The result is applied on the UI thread -- the
search's reader thread -- only if that search is still the current one.
Cancellation is checked between slices; a newer search of either kind,
ClearSearch, or unloading cancels the one in flight.

The synchronous FindInBuffer stays and now takes _terminalLock too; the UI
thread already takes it elsewhere and the reader never waits on the UI
thread unbounded while holding it.

Depends on tomlm/XTerm.NET#183 (BufferSearch.BeginScan / BufferScan.Next /
Apply(scan)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* FindInBufferAsync: don't take the terminal lock on the UI thread

The call entered _terminalLock to create the search, so the UI thread waited
out whatever chunk the reader was writing -- up to a window-info patience --
contradicting the remark that the UI thread never waits. Nothing needs the
lock there: the scan binds to the buffer in its first Next, under the lock;
the BufferSearch constructor only seeds a buffer and trim mark that Find and
Apply each record afresh; and _search is only written on the UI thread.

Test: with the lock held on another thread, the call returns before it is
released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* FindInBufferAsync: dispose the linked token source when a search ends

A search stopped only by the caller's token, or whose scan threw, left its
linked CancellationTokenSource in _searchInFlight undisposed until the next
search or clear, holding a registration on the caller's token.

The search is now the one owner of its source. However it ends (published,
cancelled, superseded or faulted) it posts to the UI thread, clears
_searchInFlight if that still points at it, and disposes the source.
CancelSearchInFlight only cancels, so nothing is disposed twice and nothing
reads a disposed source while the scan or the publish check may still use it.

Also document that an invalidated scan restarts from the top: a buffer that
keeps resizing or switching screens delays the result until it settles, while
the call stays cancellable and a newer search supersedes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Laird-McConnell <thermous@iciclecreek.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants