Skip to content

TerminalView cannot be scrolled when feeding data via DispatchQueue.main.async #486

Description

@csimlinger

Describe the bug

On iOS, TerminalView cannot be scrolled by the user when data is fed via DispatchQueue.main.async from a background thread — the standard pattern for WebSocket connections.

Feeding the same data at the same rate via Timer.scheduledTimer works perfectly.

Root cause

DispatchQueue.main.async fires during all RunLoop modes, including .tracking (active while the user is scrolling). Each feed() call triggers updateScroller() which unconditionally resets contentOffset to the bottom. This fights the scroll gesture.

Timer.scheduledTimer only fires in .default RunLoop mode, which is suspended during .tracking. So the Timer naturally pauses data delivery while the user scrolls — that's why it works.

To Reproduce

Minimal single-file repro (Demo: SwiftTermScrollBugRepro.swift.txt):

  1. Create a new iOS project, add SwiftTerm as a package dependency
  2. Replace ViewController.swift with the attached file
  3. Run on device or simulator
  4. Tap "Async" — 100 lines/sec via DispatchQueue.global()DispatchQueue.main.asyncfeed()
  5. Try scrolling up — impossible
  6. Tap "Stop", then "Timer" — same data, same rate, via Timer.scheduledTimer
  7. Try scrolling up — works fine

Both feeds deliver identical data at ~100 lines/sec. Only the dispatch mechanism differs.

Expected behavior

The user should be able to scroll up through the terminal scrollback buffer while data is being fed via DispatchQueue.main.async. This is the standard pattern for any app receiving data from WebSocket, URLSession, or other async sources.

Smartphone (please complete the following information):

  • Device: iPhone (tested on multiple models)
  • OS: iOS 18.x
  • Version: SwiftTerm main branch, Xcode 16.x

Additional context

Why this matters

Any iOS app using SwiftTerm with a network backend (WebSocket, SSH via NIO, etc.) will hit this bug, because the natural pattern is:

// Background thread receives data, dispatches to main for UI update
DispatchQueue.main.async {
    terminalView.feed(text: data)
}

This is fundamentally broken for scrolling because DispatchQueue.main.async fires during .tracking RunLoop mode.

A client-side workaround exists (buffer data and drain via Timer.scheduledTimer so delivery pauses during scroll tracking), but:

  • It requires every SwiftTerm consumer to implement the same buffer hack
  • It only works for simple text feeds — with real terminal sessions (escape sequences, cursor positioning, resize events), we still see rendering issues (lines at wrong positions, contentOffset.x drift, inability to scroll through scrollback)

The fix should be in updateScroller() itself — it should not reset contentOffset while the user is actively scrolling.

PR #468

PR #468 by @alvarolb adds a userScrolling flag that guards contentOffset updates in updateScroller(). We applied it to a local fork and it fixed the core scrolling problem — the user can scroll up while data is streaming.

However, we ran into secondary contentOffsetyDisp synchronization issues:

  1. Lines rendered in wrong order — When the circular buffer wraps during scrolling, contentOffset stays fixed but displayBuffer.lines[N] returns different content (because startIndex shifted).
  2. Can't scroll to the very top / very bottomuserScrolling stays true after the gesture ends (only cleared when user scrolls to the exact bottom). New content at the bottom becomes unreachable.
  3. Content glitch on over-scroll — When bouncing past the top of the scroll range, incorrect rows briefly appear.

We tried splitting userScrolling (finger on screen) from userScrolledBack (user not at bottom, don't auto-scroll) and various delta adjustments, but couldn't fully resolve the rendering issues (30+ test variations).

What's the recommended way to keep contentOffset and yDisp in sync during user scrolling? Should yDisp be continuously updated from contentOffset during the scroll gesture? Should contentOffset be adjusted when the buffer shifts, and if so, how to prevent it from fighting the scroll?

Any guidance would be very appreciated. Happy to test patches.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions