ITADN

Buffer reflow produces duplicate/orphan lines when narrowing terminal

#494OpenAtelierToby 创建于 2026-03-20
A
AtelierTobycommented
## Summary When a terminal is resized narrower such that existing content must soft-wrap, the buffer reflow logic in `Buffer.swift` can produce duplicate (orphan) lines that persist even when the terminal is resized back to its original width. ## Steps to reproduce 1. Create a SwiftTerm-based terminal (e.g., `LocalProcessTerminalView`) with scrollback enabled 2. In the shell, have a line of text with no hard newline that fits within the current width (e.g., the zsh prompt `% 12345` at 80 columns) 3. Resize the terminal narrower so the line must soft-wrap (e.g., from 80 columns to 6 columns) 4. Observe the display **Expected**: The line wraps correctly into 2 lines: ``` % 1234 5 ``` **Actual**: 3 lines appear, with the first line duplicated: ``` % 1234 % 1234 5 ``` 5. Resize the terminal back to the original width (80 columns) 6. The orphan line does **not** disappear — it persists as a permanent artifact ## Diagnostic evidence To isolate the issue to SwiftTerm's reflow (ruling out shell SIGWINCH redraw), I added `return` at the top of zsh's `TRAPWINCH` handler to suppress all shell-side redraw. The orphan still appears, confirming the bug originates in the reflow code itself, not in the shell's response to `SIGWINCH`. ## Root cause analysis I've identified two interacting bugs in `Buffer.swift` that together produce this behavior: ### Bug 1: Cursor-line skip + post-reflow truncation = data loss In `reflowNarrower()` (line ~916-922), lines containing the cursor are intentionally skipped: ```swift // If these lines contain the cursor don't touch them, the program will handle fixing up // wrapped lines with the cursor let absoluteY = yBase + self.y if absoluteY >= y && absoluteY < y + wrappedLines.count { continue } ``` This skip is by design — the idea is to let the running program (shell) handle cursor-adjacent content. However, after `reflow()` returns, `Buffer.resize()` unconditionally truncates **all** buffer lines to `newCols` (line ~505-508): ```swift if cols > newCols { for i in 0..<lines.maxLength { lines [i].resize (cols: newCols, fillData: CharData.Null) } } ``` This truncation destroys the overflow content on the skipped cursor line. The characters beyond `newCols` are permanently lost from the buffer. The skipped line now contains truncated content (e.g., `"% 1234"` instead of `"% 12345"`) with no corresponding wrapped continuation line and no `isWrapped` marker on any adjacent line. When the shell subsequently redraws in response to `SIGWINCH`, it outputs the full prompt/input at the current cursor position. This creates **new** properly-wrapped lines below or at the cursor. But the truncated original line remains in the buffer as an orphan — it was never reflowed, has no `isWrapped` association, and is invisible to `reflowWider()` when the terminal is later widened. ### Bug 2: `self.y` drift corrupts cursor-skip checks In `reflowNarrower()`, lines are processed bottom-to-top. For each line that needs reflowing, the viewport adjustment code modifies `self.y`: ```swift // line ~994-1015 var viewportAdjustments = linesToAdd - trimmedLines while viewportAdjustments > 0 { viewportAdjustments -= 1 if yBase == 0 { if self.y < newRows - 1 { self.y += 1 // <-- increments cursor row lines.pop () } else { yBase += 1 yDisp += 1 } } else { // ... } } ``` Since processing goes bottom-to-top, the **first** lines processed are **below** the cursor. Their insertions are below the cursor, so `self.y` should **not** change — the cursor's absolute position is unaffected by insertions below it. But the code increments `self.y` unconditionally. This causes `absoluteY` (`yBase + self.y`) to drift upward with each processed line. By the time the loop reaches the actual cursor line, `absoluteY` no longer matches the cursor's true position. The cursor-skip check (`absoluteY >= y && absoluteY < y + wrappedLines.count`) may then: - **Fail to skip the cursor line** (because `absoluteY` has drifted past it), causing double-handling when the shell also redraws - **Incorrectly skip a non-cursor line** (because `absoluteY` now points to a different line), causing content loss on an unrelated line ### How the bugs interact 1. `reflowNarrower` processes bottom lines first, each incrementing `self.y` (Bug 2) 2. By the time it reaches the cursor line, `absoluteY` has drifted — cursor-skip check produces wrong results 3. If the cursor line IS skipped (correctly or not), the post-reflow truncation destroys its overflow content (Bug 1) 4. The truncated line persists as an orphan with no `isWrapped` marker 5. Shell SIGWINCH redraw creates new wrapped content at a potentially different position 6. `reflowWider` cannot merge the orphan because it has no `isWrapped` association ## Affected code - `Sources/SwiftTerm/Buffer.swift`: - `reflowNarrower()` — cursor skip logic (line ~916-922) and viewport adjustment (line ~994-1015) - `resize()` — post-reflow line truncation (line ~505-508) - `reflow()` — dispatcher (line ~1079-1092) ## Suggested fix directions ### Option A: Reflow the cursor line (recommended) Remove the cursor-line skip in `reflowNarrower`. Instead, reflow the cursor line like any other line and adjust `self.x`/`self.y` to track the cursor's new position within the reflowed content. This is the approach used by some other terminal emulators and avoids the data loss entirely. The cursor position after reflow can be calculated: ``` newY = y + (absoluteCursorOffset / newCols) newX = absoluteCursorOffset % newCols ``` where `absoluteCursorOffset` is the cursor's character offset from the start of the wrapped line group. ### Option B: Preserve skipped-line content If the cursor-line skip is retained, the overflow content must be preserved before truncation. After `reflow()` returns but before `lines[i].resize(cols: newCols)`, detect any lines that were skipped and manually create wrapped continuation lines for their overflow content, with `isWrapped = true`. ### Fix for self.y drift (applies to both options) The viewport adjustment in `reflowNarrower` should only increment `self.y` when the insertion is **above** the cursor (i.e., when `y < absoluteY` at the time of processing). For below-cursor insertions, only `lines.pop()` is needed (to maintain viewport size), without changing `self.y`. ## Environment - SwiftTerm: latest from `main` branch (via SPM) - macOS 15.4 (Sequoia), Apple Silicon - Shell: zsh 5.9 - App: SwiftUI app using `LocalProcessTerminalView`
4 条评论