Skip to content

Commit ed1ac05

Browse files
committed
Handle cleanup of output regardless of final line ending
1 parent 78435f2 commit ed1ac05

File tree

5 files changed

+69
-149
lines changed

5 files changed

+69
-149
lines changed

helpers_unix.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ import (
99

1010
var ERR_ACCESS_DENIED = errors.New("only used on windows, this should never match")
1111

12-
func cleanPtySnapshot(b []byte, cursorPos int, _ bool) ([]byte, int) {
13-
return b, cursorPos
12+
func cleanPtySnapshot(b []byte, cursorPos int, _ bool) ([]byte, int, int) {
13+
return b, cursorPos, len(b)
1414
}

helpers_windows.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ const UnicodeBackspaceRune = '\u0008' // Note in the docs this is \u007f, but in
1212
// Ultimately we want to emulate the windows console here, just like we're doing for v10x on posix.
1313
// The current implementation is geared towards our needs, and won't be able to handle all escape sequences as a result.
1414
// For details on escape sequences see https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
15-
func cleanPtySnapshot(snapshot []byte, cursorPos int, isPosix bool) ([]byte, int) {
15+
func cleanPtySnapshot(snapshot []byte, cursorPos int, isPosix bool) (_output []byte, _cursorPos int, _cleanUptoPos int) {
1616
if isPosix {
17-
return snapshot, cursorPos
17+
return snapshot, cursorPos, len(snapshot)
1818
}
1919

2020
// Most escape sequences appear to end on `A-Za-z@`
@@ -37,7 +37,10 @@ func cleanPtySnapshot(snapshot []byte, cursorPos int, isPosix bool) ([]byte, int
3737
}
3838

3939
var result []rune
40+
var unterminatedEscape []rune
4041
runes := bytes.Runes(snapshot)
42+
escapeStartPos := -1
43+
4144
for pos, r := range runes {
4245
// Reset code recording outside of escape sequence, so we don't have to manually handle this throughout
4346
if !inEscapeSequence {
@@ -48,14 +51,15 @@ func cleanPtySnapshot(snapshot []byte, cursorPos int, isPosix bool) ([]byte, int
4851
// SEQUENCE START
4952

5053
// Delete alert / bell sequence
51-
case !inEscapeSequence && r == '\a':
54+
case !inEscapeSequence && r == UnicodeBellRune:
5255
dropPos(pos)
5356
continue
5457

5558
// Detect start of escape sequence
5659
case !inEscapeSequence && r == UnicodeEscapeRune:
5760
inEscapeSequence = true
5861
recordingCode = true
62+
escapeStartPos = pos
5963
dropPos(pos)
6064
continue
6165

@@ -71,13 +75,15 @@ func cleanPtySnapshot(snapshot []byte, cursorPos int, isPosix bool) ([]byte, int
7175
// Detect end of escape sequence
7276
case inEscapeSequence && !inTitleEscapeSequence && bytes.ContainsRune(plainVirtualEscapeSeqEndValues, r):
7377
inEscapeSequence = false
78+
escapeStartPos = -1
7479
dropPos(pos)
7580
continue
7681

7782
// Detect end of complex escape sequence
7883
case inTitleEscapeSequence && r == UnicodeBellRune:
7984
inEscapeSequence = false
8085
inTitleEscapeSequence = false
86+
escapeStartPos = -1
8187
dropPos(pos)
8288
continue
8389

@@ -108,5 +114,13 @@ func cleanPtySnapshot(snapshot []byte, cursorPos int, isPosix bool) ([]byte, int
108114
result = append(result, r)
109115
}
110116
}
111-
return []byte(string(result)), newCursorPos
117+
118+
// If we're still in an escape sequence at the end, retain the unterminated sequence
119+
cleanUptoPos := len(result)
120+
if inEscapeSequence && escapeStartPos >= 0 {
121+
unterminatedEscape = runes[escapeStartPos:]
122+
result = append(result, unterminatedEscape...)
123+
}
124+
125+
return []byte(string(result)), newCursorPos, cleanUptoPos
112126
}

helpers_windows_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ func Test_cleanPtySequences(t *testing.T) {
123123
[]byte("Python 3.9.5"),
124124
-1,
125125
},
126+
{
127+
"Unterminated escape sequence",
128+
[]byte("\x1b[?25"),
129+
0,
130+
[]byte("\x1b[?25"),
131+
-1,
132+
},
126133
}
127134
for _, tt := range tests {
128135
t.Run(tt.name, func(t *testing.T) {

outputproducer.go

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package termtest
22

33
import (
44
"bufio"
5-
"bytes"
65
"errors"
76
"fmt"
87
"io"
@@ -66,13 +65,15 @@ func (o *outputProducer) listen(r io.Reader, w io.Writer, appendBuffer func([]by
6665
var PtyEOF = errors.New("pty closed")
6766

6867
func (o *outputProducer) processNextRead(r io.Reader, w io.Writer, appendBuffer func([]byte, bool) error, size int) error {
68+
isEOF := false
6969
o.opts.Logger.Printf("processNextRead started with size: %d\n", size)
70-
defer o.opts.Logger.Println("processNextRead stopped")
70+
defer func() {
71+
o.opts.Logger.Printf("processNextRead stopped, isEOF: %v\n", isEOF)
72+
}()
7173

7274
snapshot := make([]byte, size)
7375
n, errRead := r.Read(snapshot)
7476

75-
isEOF := false
7677
if errRead != nil {
7778
pathError := &fs.PathError{}
7879
if errors.Is(errRead, fs.ErrClosed) || errors.Is(errRead, io.EOF) || (runtime.GOOS == "linux" && errors.As(errRead, &pathError)) {
@@ -116,13 +117,13 @@ func (o *outputProducer) appendBuffer(value []byte, isFinal bool) error {
116117

117118
// Clean output
118119
var err error
119-
o.output, o.cursorPos, o.cleanUptoPos, err = o.processDirtyOutput(output, o.cursorPos, o.cleanUptoPos, isFinal, func(output []byte, cursorPos int) ([]byte, int, error) {
120+
o.output, o.cursorPos, o.cleanUptoPos, err = o.processDirtyOutput(output, o.cursorPos, o.cleanUptoPos, isFinal, func(output []byte, cursorPos int) ([]byte, int, int, error) {
120121
var err error
121-
output, cursorPos = cleanPtySnapshot(output, cursorPos, o.opts.Posix)
122+
output, cursorPos, cleanCursorPos := cleanPtySnapshot(output, cursorPos, o.opts.Posix)
122123
if o.opts.OutputSanitizer != nil {
123-
output, cursorPos, err = o.opts.OutputSanitizer(output, cursorPos)
124+
output, cursorPos, cleanCursorPos, err = o.opts.OutputSanitizer(output, cursorPos)
124125
}
125-
return output, cursorPos, err
126+
return output, cursorPos, cleanCursorPos, err
126127
})
127128
if err != nil {
128129
return fmt.Errorf("cleaning output failed: %w", err)
@@ -138,48 +139,36 @@ func (o *outputProducer) appendBuffer(value []byte, isFinal bool) error {
138139
return nil
139140
}
140141

141-
type cleanerFunc func([]byte, int) ([]byte, int, error)
142+
type cleanerFunc func(snapshot []byte, cursorPos int) (newSnapshot []byte, newCursorPos int, cleanUptoPos int, err error)
142143

143144
// processDirtyOutput will sanitize the output received, but we have to be careful not to clean output that hasn't fully arrived
144145
// For example we may be inside an escape sequence and the escape sequence hasn't finished
145146
// So instead we only process new output up to the most recent line break
146147
// In order for this to work properly the invoker must ensure the output and cleanUptoPos are consistent with each other.
147148
func (o *outputProducer) processDirtyOutput(output []byte, cursorPos int, cleanUptoPos int, isFinal bool, cleaner cleanerFunc) (_output []byte, _cursorPos int, _cleanUptoPos int, _err error) {
148149
defer func() {
149-
o.opts.Logger.Printf("Cleaned output from %d to %d\n", cleanUptoPos, _cleanUptoPos)
150+
o.opts.Logger.Printf("Cleaned output from %d to %d (isFinal: %v)\n", cleanUptoPos, _cleanUptoPos, isFinal)
150151
}()
151152
alreadyCleanedOutput := copyBytes(output[:cleanUptoPos])
152153
processedOutput := []byte{}
153154
unprocessedOutput := copyBytes(output[cleanUptoPos:])
154-
processedCursorPos := cursorPos - len(alreadyCleanedOutput)
155-
156-
if isFinal {
157-
// If we've reached the end there's no point looking for the most recent line break as there's no guarantee the
158-
// output will be terminated by a newline.
159-
processedOutput = copyBytes(unprocessedOutput)
160-
unprocessedOutput = []byte{}
161-
} else {
162-
// Find the most recent line break, and only clean until that point.
163-
// Any output after the most recent line break is considered not ready for cleaning as cleaning depends on
164-
// multiple consecutive characters.
165-
lineSepN := bytes.LastIndex(unprocessedOutput, []byte("\n"))
166-
if lineSepN != -1 {
167-
processedOutput = copyBytes(unprocessedOutput[0 : lineSepN+1])
168-
unprocessedOutput = unprocessedOutput[lineSepN+1:]
169-
}
170-
}
155+
relativeCursorPos := cursorPos - len(alreadyCleanedOutput)
171156

172157
// Invoke the cleaner now that we have output that can be cleaned
173-
if len(processedOutput) > 0 {
158+
newCleanUptoPos := cleanUptoPos
159+
if len(unprocessedOutput) > 0 {
174160
var err error
175-
processedOutput, processedCursorPos, err = cleaner(processedOutput, processedCursorPos)
161+
var processedCleanUptoPos int
162+
processedOutput, relativeCursorPos, processedCleanUptoPos, err = cleaner(unprocessedOutput, relativeCursorPos)
176163
if err != nil {
177-
return processedOutput, processedCursorPos, cleanUptoPos, fmt.Errorf("cleaner failed: %w", err)
164+
return processedOutput, relativeCursorPos, processedCleanUptoPos, fmt.Errorf("cleaner failed: %w", err)
178165
}
166+
// Keep a record of what point we're up to
167+
newCleanUptoPos += processedCleanUptoPos
179168
}
180169

181170
// Convert cursor position back to absolute
182-
processedCursorPos += len(alreadyCleanedOutput)
171+
processedCursorPos := relativeCursorPos + len(alreadyCleanedOutput)
183172

184173
if processedCursorPos < 0 {
185174
// Because the cleaner function needs to support a negative cursor position it is impossible for the cleaner
@@ -188,11 +177,8 @@ func (o *outputProducer) processDirtyOutput(output []byte, cursorPos int, cleanU
188177
processedCursorPos = 0
189178
}
190179

191-
// Keep a record of what point we're up to
192-
newCleanUptoPos := cleanUptoPos + len(processedOutput)
193-
194180
// Stitch everything back together
195-
return append(append(alreadyCleanedOutput, processedOutput...), unprocessedOutput...), processedCursorPos, newCleanUptoPos, nil
181+
return append(alreadyCleanedOutput, processedOutput...), processedCursorPos, newCleanUptoPos, nil
196182
}
197183

198184
func (o *outputProducer) closeConsumers(reason error) {

0 commit comments

Comments
 (0)