Skip to content

Commit 447e713

Browse files
committed
fix: preserve terminal protocol state across output drops
1 parent 7db8f6b commit 447e713

4 files changed

Lines changed: 141 additions & 13 deletions

File tree

Sources/MikuCodeApp/TerminalANSIParser.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import Foundation
2+
13
struct TerminalANSIParser: Sendable {
24
enum Event: Sendable {
35
case ground(UInt8)
46
case clear
57
case saveCursor
68
case restoreCursor
79
case bracketedPaste(enabled: Bool)
10+
case malformed
811
case csi(parameters: [Int], final: UInt8)
912
}
1013

@@ -107,6 +110,7 @@ struct TerminalANSIParser: Sendable {
107110
csiBytes.append(byte)
108111
} else {
109112
state = .ground
113+
return .malformed
110114
}
111115
return nil
112116
}
@@ -120,6 +124,30 @@ struct TerminalANSIParser: Sendable {
120124
}
121125
}
122126

127+
struct TerminalProtocolState: Sendable {
128+
private var parser = TerminalANSIParser()
129+
private(set) var isBracketedPasteEnabled = false
130+
131+
mutating func reset(bracketedPasteEnabled: Bool = false) {
132+
parser.reset()
133+
isBracketedPasteEnabled = bracketedPasteEnabled
134+
}
135+
136+
mutating func consume(_ data: Data) {
137+
for byte in data {
138+
guard let event = parser.consume(byte) else { continue }
139+
switch event {
140+
case let .bracketedPaste(enabled):
141+
isBracketedPasteEnabled = enabled
142+
case .malformed:
143+
isBracketedPasteEnabled = false
144+
default:
145+
break
146+
}
147+
}
148+
}
149+
}
150+
123151
enum TerminalGraphicRendition {
124152
static func applying(_ rawValues: [Int], to initialStyle: TerminalStyle) -> TerminalStyle {
125153
let values = rawValues.isEmpty ? [0] : rawValues

Sources/MikuCodeApp/TerminalBuffer.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,16 @@ struct TerminalBuffer: Sendable {
7474
cursor = min(savedCursor, currentLine.cells.count)
7575
case let .bracketedPaste(enabled):
7676
isBracketedPasteEnabled = enabled
77+
case .malformed:
78+
break
7779
case let .csi(parameters, final):
7880
handleCSI(parameters: parameters, final: final)
7981
}
8082
}
83+
mutating func recoverFromInputDiscontinuity() {
84+
parser.reset()
85+
utf8Decoder.reset()
86+
}
8187
private mutating func consumeGround(_ byte: UInt8) {
8288
switch byte {
8389
case 0x1B:

Sources/MikuCodeApp/TerminalSessionModel.swift

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ final class TerminalSessionModel: ObservableObject {
4747
isBracketedPasteEnabled = buffer.isBracketedPasteEnabled
4848
publishSnapshot()
4949
}
50+
pendingOutput.reset(bracketedPasteEnabled: buffer.isBracketedPasteEnabled)
5051

5152
do {
5253
let launchSize = lastSize ?? (80, 24)
@@ -152,12 +153,18 @@ final class TerminalSessionModel: ObservableObject {
152153
}
153154

154155
private func flushPendingOutput() {
155-
let chunks = pendingOutput.takeAll()
156-
guard !chunks.isEmpty else { return }
157-
for chunk in chunks {
156+
let batch = pendingOutput.takeAll()
157+
guard !batch.chunks.isEmpty else {
158+
isBracketedPasteEnabled = batch.isBracketedPasteEnabled
159+
return
160+
}
161+
if batch.hasDiscontinuity {
162+
buffer.recoverFromInputDiscontinuity()
163+
}
164+
for chunk in batch.chunks {
158165
buffer.ingest(chunk)
159166
}
160-
isBracketedPasteEnabled = buffer.isBracketedPasteEnabled
167+
isBracketedPasteEnabled = batch.isBracketedPasteEnabled
161168
publishSnapshot()
162169
}
163170

@@ -211,39 +218,71 @@ final class TerminalSessionModel: ObservableObject {
211218
}
212219
}
213220

214-
private final class PendingTerminalOutputQueue: @unchecked Sendable {
215-
private static let maximumByteCount = 1024 * 1024
221+
struct PendingTerminalOutputBatch: Sendable {
222+
let chunks: [Data]
223+
let hasDiscontinuity: Bool
224+
let isBracketedPasteEnabled: Bool
225+
}
226+
227+
final class PendingTerminalOutputQueue: @unchecked Sendable {
228+
private let maximumByteCount: Int
216229

217230
private let lock = NSLock()
218231
private var chunks: [Data] = []
219232
private var byteCount = 0
233+
private var hasDiscontinuity = false
234+
private var protocolState = TerminalProtocolState()
235+
236+
init(maximumByteCount: Int = 1024 * 1024) {
237+
self.maximumByteCount = max(1, maximumByteCount)
238+
}
239+
240+
func reset(bracketedPasteEnabled: Bool = false) {
241+
lock.lock()
242+
defer { lock.unlock() }
243+
chunks.removeAll(keepingCapacity: true)
244+
byteCount = 0
245+
hasDiscontinuity = false
246+
protocolState.reset(bracketedPasteEnabled: bracketedPasteEnabled)
247+
}
220248

221249
func append(_ data: Data) -> Bool {
222250
guard !data.isEmpty else { return false }
223251
lock.lock()
224252
defer { lock.unlock() }
225253

226254
let wasEmpty = byteCount == 0
227-
if data.count >= Self.maximumByteCount {
228-
chunks = [Data(data.suffix(Self.maximumByteCount))]
229-
byteCount = Self.maximumByteCount
255+
protocolState.consume(data)
256+
if data.count >= maximumByteCount {
257+
chunks = [Data(data.suffix(maximumByteCount))]
258+
byteCount = maximumByteCount
259+
hasDiscontinuity = hasDiscontinuity || data.count > maximumByteCount || !wasEmpty
230260
return wasEmpty
231261
}
232262

233263
chunks.append(data)
234264
byteCount += data.count
235-
dropOldestBytes(byteCount - Self.maximumByteCount)
265+
let droppedByteCount = byteCount - maximumByteCount
266+
if droppedByteCount > 0 {
267+
hasDiscontinuity = true
268+
dropOldestBytes(droppedByteCount)
269+
}
236270
return wasEmpty
237271
}
238272

239-
func takeAll() -> [Data] {
273+
func takeAll() -> PendingTerminalOutputBatch {
240274
lock.lock()
241275
defer { lock.unlock() }
242276

243-
let pending = chunks
277+
let batch = PendingTerminalOutputBatch(
278+
chunks: chunks,
279+
hasDiscontinuity: hasDiscontinuity,
280+
isBracketedPasteEnabled: protocolState.isBracketedPasteEnabled
281+
)
244282
chunks.removeAll(keepingCapacity: true)
245283
byteCount = 0
246-
return pending
284+
hasDiscontinuity = false
285+
return batch
247286
}
248287

249288
private func dropOldestBytes(_ count: Int) {

Tests/MikuCodeAppTests/TerminalBufferTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,61 @@ final class TerminalBufferTests: XCTestCase {
112112
XCTAssertFalse(buffer.isBracketedPasteEnabled)
113113
}
114114

115+
func testRecoversVisualParserAfterDroppedUTF8AndANSIPrefixes() {
116+
let queue = PendingTerminalOutputQueue(maximumByteCount: 8)
117+
let oversized = Data("drop".utf8)
118+
+ Data([0xEB, 0xB0, 0x1B, 0x5B, 0xB8])
119+
+ Data("31mkept".utf8)
120+
XCTAssertTrue(queue.append(oversized))
121+
122+
let batch = queue.takeAll()
123+
XCTAssertTrue(batch.hasDiscontinuity)
124+
125+
var buffer = TerminalBuffer()
126+
buffer.recoverFromInputDiscontinuity()
127+
for chunk in batch.chunks {
128+
buffer.ingest(chunk)
129+
}
130+
131+
XCTAssertEqual(buffer.snapshot.plainText, "�31mkept")
132+
XCTAssertEqual(buffer.snapshot.lines[0].cells.last?.style, .default)
133+
}
134+
135+
func testProtocolModeSurvivesDisplayDropAndTracksLaterDisable() {
136+
let queue = PendingTerminalOutputQueue(maximumByteCount: 8)
137+
var buffer = TerminalBuffer()
138+
139+
XCTAssertTrue(queue.append(Data("prefix\u{1B}[?2004h".utf8)))
140+
XCTAssertFalse(queue.append(Data("oversized".utf8)))
141+
142+
let droppedBatch = queue.takeAll()
143+
XCTAssertTrue(droppedBatch.hasDiscontinuity)
144+
XCTAssertTrue(droppedBatch.isBracketedPasteEnabled)
145+
146+
buffer.recoverFromInputDiscontinuity()
147+
for chunk in droppedBatch.chunks {
148+
buffer.ingest(chunk)
149+
}
150+
151+
XCTAssertTrue(queue.append(Data("\u{1B}[?2004l".utf8)))
152+
let disabledBatch = queue.takeAll()
153+
XCTAssertFalse(disabledBatch.isBracketedPasteEnabled)
154+
for chunk in disabledBatch.chunks {
155+
buffer.ingest(chunk)
156+
}
157+
XCTAssertFalse(buffer.isBracketedPasteEnabled)
158+
}
159+
160+
func testProtocolModeFailsClosedOnOversizedCSI() {
161+
var state = TerminalProtocolState()
162+
state.consume(Data("\u{1B}[?2004h".utf8))
163+
XCTAssertTrue(state.isBracketedPasteEnabled)
164+
165+
state.consume(Data([0x1B, 0x5B] + Array(repeating: 0x31, count: 65)))
166+
167+
XCTAssertFalse(state.isBracketedPasteEnabled)
168+
}
169+
115170
func testCapsCombiningScalarsWithoutGrowingStoredCells() {
116171
let maximumScalarsPerCell = 32
117172
var buffer = TerminalBuffer(maxLines: 1, maxCells: 1)

0 commit comments

Comments
 (0)