Reproduce fuzz crash with test case
The test fails with exactly the same panic as the fuzzer crash: page integrity violation in clearCells called from insertBlanks at line 2270.
Crash file reproduced: afl-out/stream/default/crashes/id:000035,sig:06,src:004549,time:18620097,execs:2952067,op:havoc,rep:2
Bug: When insertBlanks has scroll_amount == 0 (clearing the entire cursor-to-right-margin region), it doesn't check for a wide character at the right margin whose spacer_tail extends beyond it. The clearCells at line 2270 clears the wide head at the right margin but leaves the orphaned spacer_tail at right_margin + 1.
The fix is to check for a wide char at the end of the cleared range (left + adjusted_count - 1) whose spacer_tail sits just beyond it — regardless of whether there's a shift. Currently this check only exists inside the if (scroll_amount > 0) block (lines 2252–2259, the dst_end check), but the same situation occurs when scroll_amount == 0.
The simplest fix: move the dst_end wide-char check to run before the final clearCells, outside the scroll_amount > 0 block. Concretely, right before line 2270, check if the last cell being cleared is .wide and if so, also clear its spacer_tail:
// If the last cell we're about to clear is wide, its spacer tail
// is just beyond our range and won't be cleared. Clear it to avoid
// orphaning it. This can happen both with and without a shift
// (scroll_amount == 0 means we clear the entire margin region).
const clear_end: *Cell = @ptrCast(left + (adjusted_count - 1));
if (clear_end.wide == .wide) {
const tail: *Cell = @ptrCast(left + adjusted_count);
assert(tail.wide == .spacer_tail);
tail.wide = .narrow;
}
This could also replace the existing dst_end check inside the scroll_amount > 0 block since when scroll_amount > 0, left + adjusted_count - 1 equals dst_end (both are left + rem - 1 only when scroll_amount == 0, but after the shift the same cell ends up at the boundary). However, after the shift, the content at dst_end may have changed, so the safest minimal fix is to just add the check before line 2270 without removing the existing one.
Yes. rowWillBeShifted cleans up wide chars that straddle scroll region boundaries — including the exact pattern at lines 1768–1777: if the cell at right is .wide, it clears both that cell and the spacer_tail at right + 1.
The lesson is that insertBlanks should do the same boundary cleanup before it starts modifying cells, not after. And it should handle both boundaries — a spacer_tail at left (already done at line 2215) and a wide char at right whose tail is beyond the margin.
So the cleaner fix: add a check right after the existing spacer_tail check (around line 2218), before any shifting or clearing begins:
// If the cell at the right margin is wide, its spacer tail is
// outside the scroll region and would be orphaned by either the
// shift or the clear. Clean up both halves up front.
const right_cell: *Cell = @ptrCast(left + (rem - 1));
if (right_cell.wide == .wide) {
self.screens.active.clearCells(page, self.screens.active.cursor.page_row, right_cell[0..2]);
}
This also lets us remove the duplicate dst_end check inside the scroll_amount > 0 block (lines 2249–2259), since the boundary is already clean before the shift starts — same pattern as rowWillBeShifted which cleans boundaries once up front rather than conditionally.
Crash id:000035 no longer reproduces. The fix moved the right-margin wide-char cleanup from inside the scroll_amount > 0 block to before it, so it runs unconditionally — matching the rowWillBeShifted pattern of cleaning up boundary-straddling wide chars up front.
Done.