Repository navigation
BufferSearch: fix the Trimmed race; stateful BufferScan for off-thread search - #183
Conversation
…; 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
left a comment
There was a problem hiding this comment.
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
Scanmixes 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 singletrimMarkpassed toApplycan be right for both. The highlights land N rows off, orApplythrows on the row-order check. No test covers a trim between slices. (inline onTrimMarkandSearchScan.NextRow)
Notes (checked, fine or low impact)
CatchUpWithTrimsonHitsOnRowis one volatilelongread plus a compare. The method is small enough to inline, andRebaseis only called on a mismatch. That fits the hot-path guidance in CLAUDE.md.Rebasefolding 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 - droppedadjustment is unchanged from main and still right, because dropped hits are always a prefix.Findreads the mark before scanning and publishes with it. Under the terminal lock no trim can land in between, so_rebasedTois exact.ScanCore'srow = runEndskip gives the same result as the old loop, which reached continuation rows and skipped them onIsWrapped.NextRow = max(runEnd+1, end)handles a run that wraps pastendRow. 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
Scanremarks say rows outside the buffer are clamped. With a non-empty needle andstartRow > Lines.Length,ScanCorereturnsNextRow = startRowunclamped. The empty-needle branch returnsclamp(endRow)instead, so the two branches disagree onNextRowwhenstartRow > endRow. Astart < lengthloop still terminates, so there is no failure; it is only a doc mismatch. - Low:
Apply'strimMark > TrimMarkcheck cannot catch a mark taken from a differentBufferSearchinstance. Each instance's counter starts at 0 when it is constructed.
| /// </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 — |
There was a problem hiding this comment.
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
NextRow50 -> 45, asSearchScan.NextRowsays. - 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 ->
Applyshifts nothing. The first two highlights draw 5 rows below their text. - Mark read at the start instead ->
Applyshifts 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.ApplythrowsArgumentExceptionfrom 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
Scantake the mark the list is currently in and return the mark it scanned at, shifting/droppinginto's existing hits (and returning a shiftedNextRow) when they differ.Applythen 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.
There was a problem hiding this comment.
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:
BeginScanreturns a statefulBufferScan.- 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
Finddoes.
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) andA_scan_resumed_on_a_wrapped_line_at_row_0_searches_it_once, both compared against a freshFind.- Mutants that shift only the hits, only the row, or resume one row early are each caught.
| /// <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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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>
|
Thanks. The High finding was right; fixed in 13be8d9 with a stateful Both Low notes are moot now:
Also new:
|
1a5961c to
13be8d9
Compare
…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>
The race
A host raises
TerminalBuffer.Trimmedon its PTY reader thread.BufferSearch.OnTrimmedcompacted the hit list and calledRemoveRangeon it right there. Meanwhile the UI thread could be stepping through the hits, or drawing from aHitsOnRowspan 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.BufferSearchno longer subscribes toTrimmedat all. Its reader thread compares the count with the mark its hits were last rebased to. That is one volatilelongread per call. It shifts the hits beforeCount,CurrentIndex,HitsOnRowor 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.
Findalso searches the screen buffer now, rather than the one-row status buffer while a program writes its status line.Off-UI-thread search:
BufferScanA host can search in slices, releasing its terminal lock between them.
Nextfirst 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.NextreportsInvalidatedon 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.BeginScancan be called on any thread.Nextruns on any thread holding the terminal lock, one call at a time.Applyruns only on the search's reader thread, after the lastNext, and needs no lock: lines trimmed in transit are applied on the next read.Applythrows unless the scan isDoneorTruncated, or if it was begun on another search.Tests
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.A_trim_between_slices_leaves_exactly_what_a_fresh_Find_findsuses trims of 1, 3, 7 and 25 lines. The same scenario, following the previous head's docs, gave rows that were off, anArgumentExceptionfrom the order check, or stale hits.A_scan_resumed_on_a_wrapped_line_at_row_0_searches_it_once.Donebut beforeApplyare still applied.Applyrefuses an unfinished scan, or another search's scan.BufferSearchTestspass. The wholeXTerm.NET.Testsproject: 2351 passed, 1 skipped, 0 failed.HitsOnRowskipping 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-asciicorpus measures it.🤖 Generated with Claude Code