Skip to content

OutputBase.Write emits one cursor-positioning call per clean cell, making every redraw cost rows × cols syscalls #5665

Description

@Scripturd

Version: Terminal.Gui 2.4.17 · net10.0 · Windows 11 (10.0.26200) · default (ansi) driver, but all three drivers are affected.

Summary

OutputBase.Write(IOutputBuffer) walks each dirty row cell by cell. For every cell it finds clean — i.e. every cell it is skipping — it calls SetCursorPositionImpl, which on every driver performs real I/O.

Because OutputBufferImpl marks DirtyLines[Row] = true unconditionally whenever a grapheme is written to a row (while cell-level IsDirty is correctly set only when content actually changes), a steady-state frame where nothing has changed still has every row flagged dirty. The result is a full rows × cols scan issuing one cursor-positioning syscall per cell, producing no visible output.

In a default dotnet new tui app this makes a mouse-hover repaint cost ~100 ms, i.e. ~10 fps, and it is what users perceive as the UI lagging up to a second behind the mouse.

The code

Terminal.Gui/Drivers/Output/OutputBase.cs, in Write(IOutputBuffer) (decompiled from 2.4.17):

for (int j = num; j < cols; j++)
{
    lastCol = -1;
    int outputWidth = 0;
    for (; j < cols; j++)
    {
        bool flag3 = IsRasterCoveredBlankCell(buffer, i, j, rasterCellRectangles);
        if (!buffer.Contents[i, j].IsDirty | flag3)          // cell is CLEAN
        {
            if (flag3) { buffer.Contents[i, j].IsDirty = false; }
            if (stringBuilder.Length > 0)
                WriteToConsole(stringBuilder, ref lastCol, ref outputWidth);
            else if (lastCol == -1)
                lastCol = j;
            if (lastCol + 1 < cols) { lastCol++; }
            SetCursorPositionImpl(lastCol, i);               // ← one I/O op per skipped cell
            continue;
        }
        // ...
    }
}

lastCol advances by one each iteration, so the position always differs and the call always performs I/O:

  • ansi — AnsiOutput.SetCursorPositionImpl → Write(ReadOnlySpan<char>), which allocates a fresh StringBuilder, does Encoding.UTF8.GetBytes, and issues its own WriteFile for a ~7-byte CSI r;cH.
  • dotnet — NetOutput.SetCursorPositionImpl → Console.SetCursorPosition inside a bare try {} catch {}. That calls ConsolePal.GetBufferInfo, and where the buffer can't be queried it throws and swallows a Win32 exception per cell.

Measurements

Stock dotnet new tui app, unmodified except for instrumentation, 6-second runs with synthetic mouse-move events over the button:

iterations/s frame p50 frame p90
stock 8 110 ms 135 ms
with cursor moves coalesced 44 15.8 ms 31 ms
  • Dirty rows per frame: 30 of 30 (p50 = p90 = max), ~3,540 cells scanned per frame.
  • SetCursorPositionImpl calls: 861,245 across 246 frames — 538 of which actually needed to move the cursor. 99.94% of the I/O is redundant.
  • 51 of 52 frames wrote zero characters to the terminal. The ~100 ms is spent entirely on cursor calls that render nothing.
  • 8 of 8 sampled stacks (dotnet-stack) sat in OutputBase.Write → SetCursorPositionImpl → ConsolePal.SetCursorPosition → GetBufferInfo, four of them inside Win32Marshal.GetExceptionForWin32Error / EH.DispatchEx.

The cost scales with terminal area, and with per-write cost in the host — through ConPTY (VS Code's integrated terminal), where each write round-trips, the same ~3,500 syscalls comfortably reach a second per frame.

Mouse dispatch itself is not implicated: injecting two mouse events costs 0.1 ms, and hover → MouseStateChanged latency is p50 0.0 ms.

Reproducing

  1. dotnet new tui and run it.
  2. Move the mouse back and forth across the Increment button.
  3. The highlight lags conspicuously; in VS Code's integrated terminal it can be ~1 s behind.

To see it directly, count calls into SetCursorPositionImpl versus the number that change the position.

Suggested fix

Don't emit while skipping. Track the desired position and flush at most one cursor escape immediately before content that actually needs it:

protected override bool SetCursorPositionImpl (int col, int row)
{
    _want = new Point (col, row);   // record only
    return true;
}

protected override void Write (StringBuilder output)
{
    if (_want is { } w && w != _emitted)
    {
        _pending.Append (EscSeqUtils.CSI_SetCursorPosition (w.Y + 1, w.X + 1));
        _emitted = w;
    }
    _pending.Append (output);       // one write per frame
}

That is what produced the 44 iterations/s column above. I verified it is equivalent and not merely faster: replaying both escape streams through a terminal model yields identical cell grids, and the two streams are byte-identical once redundant cursor escapes are stripped.

Two things are worth fixing independently:

  1. The scan itself — skipping clean cells should advance a counter, not perform I/O.
  2. DirtyLines[Row] = true being set unconditionally on every grapheme write in OutputBufferImpl, even when the cell content is unchanged. Gating it on an actual change would mean an idle frame scans nothing at all.

Note on working around it

There is currently no supported way for an application to work around this, because ApplicationImpl is internal — so although AnsiComponentFactory accepts an IOutput, nothing public can construct an application with a custom one. The only route is reflection over ApplicationImpl(IComponentFactory). Making that constructor (or an equivalent factory hook) public would give applications an escape hatch.

Environment

OS:              Microsoft Windows 11 Home 10.0.26200
Terminal:        Windows Terminal 1.24.11911.0
PowerShell:      5.1.26100.9278
.NET SDK:        10.0.204
TargetFramework: net10.0
Terminal.Gui:    2.4.17 (NuGet PackageReference)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions