Bug description
Buffer.scrollUp and Buffer.scrollDown corrupt the IndexAwareCircularBuffer by creating dangling references, leading to an assertion failure ('attached': is not true) or a null dereference in release mode during subsequent Terminal.write() calls.
Root cause
Both methods move BufferLine objects between positions using operator[]=:
// buffer.dart, scrollUp
for (var i = absoluteMarginTop; i <= absoluteMarginBottom; i++) {
if (i <= absoluteMarginBottom - lines) {
this.lines[i] = this.lines[i + lines]; // ← bug
} else {
this.lines[i] = _newEmptyLine();
}
}
operator[]= calls _adoptChild(i, child) which:
- Detaches whatever was at cyclic slot
i
- Attaches
child at cyclic slot i
But child (read from position i + lines) is still referenced from its old cyclic slot (i + lines). On the next iteration, _adoptChild(i + 1, ...) detaches whatever is at cyclic slot i + 1 — which is the same BufferLine object we just adopted at slot i. Now slot i holds a BufferLine with _owner == null.
Trace for scrollUp(1), marginTop=5, marginBottom=20:
| Iteration |
Operation |
Effect |
| i=5 |
lines[5] = lines[6] |
item₆ adopted at slot 5; slot 6 still refs item₆ |
| i=6 |
lines[6] = lines[7] |
_adoptChild(6, item₇) detaches item₆ via slot 6 → slot 5 now holds detached item₆ |
| i=7… |
continues |
Each iteration detaches the item placed in the previous iteration |
After scrollUp(1), every position from marginTop to marginBottom - 1 holds a detached BufferLine.
Trigger
When a subsequent Buffer.index() → lines.insert() shifts elements via _moveChild, it calls _move() on the detached BufferLine:
IndexedItem._move: assert(attached) ← FAILS
This is very common in practice because scrollUp is called from Buffer.index() whenever scroll regions are active (e.g. tmux status bar, vim, less, htop, any program using \e[T;Br).
Error output
Unhandled Exception: 'package:xterm/src/utils/circular_buffer.dart': Failed assertion: line 312 pos 12: 'attached': is not true.
#0 _AssertionError._doThrowNew
#1 _AssertionError._throwNew
#2 IndexedItem._move (circular_buffer.dart:312)
#3 IndexAwareCircularBuffer._moveChild (circular_buffer.dart:52)
#4 IndexAwareCircularBuffer.insert (circular_buffer.dart:192)
#5 Buffer.index (buffer.dart:239)
#6 Buffer.lineFeed (buffer.dart:264)
#7 Terminal.lineFeed (terminal.dart:418)
#8 EscapeParser._processChar (parser.dart:63)
#9 Terminal.write (terminal.dart:228)
In release mode (assertions disabled), _move dereferences _owner!._absoluteStartIndex on the null _owner, causing a TypeError / null check error.
Suggested fix
Copy items to a temporary buffer before reassigning, so the source slot is not still referencing the moved item when the target slot is updated:
void scrollUp(int count) {
final top = absoluteMarginTop;
final bottom = absoluteMarginBottom;
// Collect items that will be overwritten (they scroll off the top).
// Then shift remaining items down and fill the gap with empty lines.
final kept = <BufferLine>[];
for (var i = top + count; i <= bottom; i++) {
kept.add(lines[i]);
}
for (var i = 0; i < kept.length; i++) {
lines[top + i] = kept[i];
}
for (var i = bottom - count + 1; i <= bottom; i++) {
lines[i] = _newEmptyLine();
}
}
Or equivalently, null out old slots before the assignment loop to break the shared reference.
The same fix is needed for scrollDown.
Environment
- xterm: 4.0.0
- Flutter: 3.29.x
- Dart: 3.7.x
- Observed on Android (ARM64), reproducible on any platform with scroll-region-heavy terminal output
Bug description
Buffer.scrollUpandBuffer.scrollDowncorrupt theIndexAwareCircularBufferby creating dangling references, leading to an assertion failure ('attached': is not true) or a null dereference in release mode during subsequentTerminal.write()calls.Root cause
Both methods move
BufferLineobjects between positions usingoperator[]=:operator[]=calls_adoptChild(i, child)which:ichildat cyclic slotiBut
child(read from positioni + lines) is still referenced from its old cyclic slot (i + lines). On the next iteration,_adoptChild(i + 1, ...)detaches whatever is at cyclic sloti + 1— which is the sameBufferLineobject we just adopted at sloti. Now slotiholds aBufferLinewith_owner == null.Trace for
scrollUp(1), marginTop=5, marginBottom=20:lines[5] = lines[6]lines[6] = lines[7]_adoptChild(6, item₇)detaches item₆ via slot 6 → slot 5 now holds detached item₆After
scrollUp(1), every position frommarginToptomarginBottom - 1holds a detachedBufferLine.Trigger
When a subsequent
Buffer.index()→lines.insert()shifts elements via_moveChild, it calls_move()on the detachedBufferLine:This is very common in practice because
scrollUpis called fromBuffer.index()whenever scroll regions are active (e.g. tmux status bar, vim, less, htop, any program using\e[T;Br).Error output
In release mode (assertions disabled),
_movedereferences_owner!._absoluteStartIndexon the null_owner, causing aTypeError/ null check error.Suggested fix
Copy items to a temporary buffer before reassigning, so the source slot is not still referencing the moved item when the target slot is updated:
Or equivalently, null out old slots before the assignment loop to break the shared reference.
The same fix is needed for
scrollDown.Environment