Skip to content

scrollUp/scrollDown corrupt IndexAwareCircularBuffer — detached BufferLine assertion failure #222

@michnovka

Description

@michnovka

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:

  1. Detaches whatever was at cyclic slot i
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions