Skip to content

Commit aefc86c

Browse files
committed
Add scrollback with parsing
We have to parse to avoid storing alternate screen content because it is not possible to provide scrollback for the alternate screen. Applications can do all sorts of things in the alternate screen so trying to store and replay messages results in undefined behavior. For example an application may ask the client for capabilities or cursor positions. Replaying that will make the client re-send that information on reconnect. If the application is no longer running those responses will just be inserted as regular text into the terminal. We could throw away the alternate screen buffer contents when it exits to avoid that specific problem but we still would have a problem when the alternate screen is still up because at the point of reconnection the application is no longer expecting those responses, causing undefined behavior (for example Emacs will sometimes insert the unasked-for codes into the file you are editing as regular text and sometimes will ignore them). I suspect there could be other issues as well that would arise from the ring buffer cutting off a portion of the alternate buffer content. We could instead store the entire current visible state of the alternate buffer but that would require we implement a lot more parsing. More or less we would need to re-implement a terminal emulator so we can render everything correctly. Another idea is to figure out what codes the application is sending that makes the client respond and strip them out of the scrollback buffer but this could be tricky depending on how many codes there are and we might still run the risk of undefined behavior due to the ring buffer interfering with the alternate screen buffer content. (As a silly example imagine the application says to move the cursor to line X then later says to move the cursor up X times but the ring buffer cuts off that first instruction---where would the cursor go?) The current solution is to resize which forces the app to redraw. Since a redraw does not happen if the size is unchanged we resize once a bit smaller then again to the regular size to force the redraw.
1 parent 698c66a commit aefc86c

File tree

5 files changed

+183
-11
lines changed

5 files changed

+183
-11
lines changed

client.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ func RemoteExecer(conn *websocket.Conn) Execer {
2828
// Command represents an external command to be run
2929
type Command struct {
3030
// ID allows reconnecting commands that have a TTY.
31-
ID string
31+
ID string
32+
// Rows and cols are used when reconnecting commands that have a TTY.
33+
Rows uint16
34+
Cols uint16
3235
Command string
3336
Args []string
3437
TTY bool
@@ -44,6 +47,8 @@ func (r remoteExec) Start(ctx context.Context, c Command) (Process, error) {
4447
ID: c.ID,
4548
Command: mapToProtoCmd(c),
4649
Type: proto.TypeStart,
50+
Rows: c.Rows,
51+
Cols: c.Cols,
4752
}
4853
payload, err := json.Marshal(header)
4954
if err != nil {

internal/proto/clientmsg.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type ClientStartHeader struct {
2020
Type string `json:"type"`
2121
ID string `json:"id"`
2222
Command Command `json:"command"`
23+
Rows uint16 `json:"rows"`
24+
Cols uint16 `json:"cols"`
2325
}
2426

2527
// Command represents a runnable command.

scrollback.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package wsep
2+
3+
import (
4+
"strings"
5+
6+
"github.com/armon/circbuf"
7+
)
8+
9+
// ControlSequenceType represents the purpose of a control sequence.
10+
type ControlSequenceType int32
11+
12+
// CSI: Control sequence indicator (marks the beginning of a control sequence).
13+
// Pm: Any number of single numeric parameters separated by ;.
14+
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Control-Bytes_-Characters_-and-Sequences
15+
const (
16+
PrivateModeSet ControlSequenceType = 0 // CSI ? Pm h
17+
PrivateModeReset ControlSequenceType = 1 // CSI ? Pm l
18+
)
19+
20+
// ControlSequence represents a code that has special meaning in the terminal.
21+
type ControlSequence struct {
22+
Type ControlSequenceType
23+
Parameters []string
24+
}
25+
26+
// Scrollback stores scrollback from a process.
27+
type Scrollback struct {
28+
ringBuffer *circbuf.Buffer
29+
altMode bool
30+
}
31+
32+
// NewScrollback creates a new scrollback buffer.
33+
func NewScrollback(size int64) (*Scrollback, error) {
34+
ringBuffer, err := circbuf.NewBuffer(size)
35+
if err != nil {
36+
return nil, err
37+
}
38+
return &Scrollback{
39+
ringBuffer: ringBuffer,
40+
altMode: false,
41+
}, nil
42+
}
43+
44+
// Write stores scrollback from the provided bytes. Scrollback is only stored
45+
// for the regular screen; the alternate screen buffer is ignored.
46+
func (s *Scrollback) Write(bytes []byte) error {
47+
// Write the selected bytes into the ring buffer.
48+
write := func(start, end int) error {
49+
if !s.altMode && end > start {
50+
_, err := s.ringBuffer.Write(bytes[start:end])
51+
return err
52+
}
53+
return nil
54+
}
55+
56+
start := 0 // Marks the start of non-alt buffer contents.
57+
pos := 0 // Marks the current position in the buffer.
58+
59+
for pos < len(bytes) {
60+
sequence, end := parseControlSequence(bytes, pos)
61+
// Check if the sequence is for the alternate screen.
62+
if sequence != nil && len(sequence.Parameters) == 1 && sequence.Parameters[0] == "1049" {
63+
switch sequence.Type {
64+
case PrivateModeSet:
65+
err := write(start, pos) // Store the non-alt buffer content so far.
66+
if err != nil {
67+
return err
68+
}
69+
s.altMode = true
70+
case PrivateModeReset:
71+
s.altMode = false
72+
start = end // Non-alt buffer content starts here now.
73+
}
74+
}
75+
pos = end // Move past the sequence or to the next byte.
76+
}
77+
78+
return write(start, pos) // Write any remaining non-alt buffer content.
79+
}
80+
81+
// parseControlSequence returns the control sequence found at the specified
82+
// position if any along with the new position. It only supports a small subset
83+
// of control sequences. If there is no sequence advance the position by one.
84+
func parseControlSequence(bytes []byte, pos int) (*ControlSequence, int) {
85+
if pos+3 < len(bytes) && bytes[pos] == '\x1b' && bytes[pos+1] == '[' { // ESC [ is the CSI.
86+
switch mode := bytes[pos+2]; mode { // Next is the prefix character.
87+
case '?':
88+
params, end := parsePm(bytes, pos+3) // Then the arguments.
89+
if end < len(bytes) {
90+
switch bytes[end] { // Then a character describing the sequence type.
91+
case 'h':
92+
return &ControlSequence{
93+
Type: PrivateModeSet,
94+
Parameters: params,
95+
}, end + 1
96+
case 'l':
97+
return &ControlSequence{
98+
Type: PrivateModeReset,
99+
Parameters: params,
100+
}, end + 1
101+
}
102+
}
103+
}
104+
}
105+
return nil, pos + 1
106+
}
107+
108+
// parsePm returns the parameters for the sequence found at the specified
109+
// position along with the new position.
110+
func parsePm(bytes []byte, pos int) ([]string, int) {
111+
start := pos
112+
for pos < len(bytes) {
113+
c := bytes[pos]
114+
if !isDigit(c) && c != ';' {
115+
break
116+
}
117+
pos++
118+
}
119+
return strings.Split(string(bytes[start:pos]), ";"), pos
120+
}
121+
122+
func isDigit(c byte) bool {
123+
return c >= '0' && c <= '9'
124+
}
125+
126+
// Get returns the scrollback buffer.
127+
func (s *Scrollback) Get() []byte {
128+
return s.ringBuffer.Bytes()
129+
}
130+
131+
// AltMode returns true if currently display the alternate screen.
132+
func (s *Scrollback) AltMode() bool {
133+
return s.altMode
134+
}
135+
136+
// Reset resets the scrollback buffer.
137+
func (s *Scrollback) Reset() {
138+
s.ringBuffer.Reset()
139+
}

server.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"sync"
1111
"time"
1212

13-
"github.com/armon/circbuf"
1413
"github.com/google/uuid"
1514

1615
"go.coder.com/flog"
@@ -119,18 +118,18 @@ func Serve(ctx context.Context, c *websocket.Conn, execer Execer, options *Optio
119118
}
120119

121120
// Default to buffer 64KB.
122-
ringBuffer, err := circbuf.NewBuffer(64 * 1024)
121+
scrollback, err := NewScrollback(64 * 1024)
123122
if err != nil {
124123
cancel()
125-
return xerrors.Errorf("unable to create ring buffer %w", err)
124+
return xerrors.Errorf("unable to create scrollback buffer %w", err)
126125
}
127126

128127
rprocess = &reconnectingProcess{
129128
activeConns: make(map[string]net.Conn),
130129
process: process,
131130
// Timeouts created with AfterFunc can be reset.
132131
timeout: time.AfterFunc(options.ReconnectingProcessTimeout, cancel),
133-
ringBuffer: ringBuffer,
132+
scrollback: scrollback,
134133
}
135134
reconnectingProcesses.Store(header.ID, rprocess)
136135

@@ -161,12 +160,13 @@ func Serve(ctx context.Context, c *websocket.Conn, execer Execer, options *Optio
161160
break
162161
}
163162
part := buffer[:read]
164-
_, err = rprocess.ringBuffer.Write(part)
163+
err = rprocess.scrollback.Write(part)
165164
if err != nil {
166-
flog.Error("reconnecting process %s write buffer: %v", header.ID, err)
165+
flog.Error("reconnecting process %s save scrollback: %v", header.ID, err)
167166
cancel()
168167
break
169168
}
169+
170170
rprocess.activeConnsMutex.Lock()
171171
for _, conn := range rprocess.activeConns {
172172
_ = sendOutput(ctx, part, conn)
@@ -181,12 +181,36 @@ func Serve(ctx context.Context, c *websocket.Conn, execer Execer, options *Optio
181181
flog.Error("failed to send pid %d", process.Pid())
182182
}
183183

184-
// Write out the initial contents in the ring buffer.
185-
err = sendOutput(ctx, rprocess.ringBuffer.Bytes(), wsNetConn)
184+
// Write out the scrollback buffer.
185+
err = sendOutput(ctx, rprocess.scrollback.Get(), wsNetConn)
186186
if err != nil {
187187
return xerrors.Errorf("write reconnecting process %s buffer: %w", header.ID, err)
188188
}
189189

190+
// Resize to redraw any applications that might be displaying.
191+
if rprocess.scrollback.AltMode() && header.Rows != 0 && header.Cols != 0 {
192+
// Kick the client into alt mode and the cursor into the home
193+
// position.
194+
err = sendOutput(ctx, []byte("\x1b[?1049h\x1b[H"), wsNetConn)
195+
if err != nil {
196+
return xerrors.Errorf("write reconnecting process %s buffer: %w", header.ID, err)
197+
}
198+
199+
// Unfortunately a resize does nothing if the size has not changed.
200+
// We can force a resize by sending a SIGWINCH to the displaying app
201+
// but how do we get the PID? For now resize twice as a work around.
202+
// TODO: Look into either making SIGWINCH work or fully implementing a
203+
// terminal emulator so we can get and replay the alternate screen.
204+
err = process.Resize(ctx, header.Rows-1, header.Cols-1)
205+
if err != nil {
206+
return xerrors.Errorf("resize reconnecting process: %w", err)
207+
}
208+
err = process.Resize(ctx, header.Rows, header.Cols)
209+
if err != nil {
210+
return xerrors.Errorf("resize reconnecting process: %w", err)
211+
}
212+
}
213+
190214
// Store this connection on the reconnecting process. All connections
191215
// stored on the process will receive the process's stdout.
192216
connectionID := uuid.NewString()
@@ -330,7 +354,7 @@ type reconnectingProcess struct {
330354
activeConnsMutex sync.Mutex
331355
activeConns map[string]net.Conn
332356

333-
ringBuffer *circbuf.Buffer
357+
scrollback *Scrollback
334358
timeout *time.Timer
335359
process Process
336360
}
@@ -344,5 +368,5 @@ func (r *reconnectingProcess) Close() {
344368
_ = conn.Close()
345369
}
346370
_ = r.process.Close()
347-
r.ringBuffer.Reset()
371+
r.scrollback.Reset()
348372
}

tty_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ func TestReconnectTTY(t *testing.T) {
169169
Command: "sh",
170170
TTY: true,
171171
Stdin: true,
172+
Rows: 1000,
173+
Cols: 1000,
172174
}
173175

174176
ws, server := mockConn(ctx, t, &Options{

0 commit comments

Comments
 (0)