Skip to content

Commit da995d8

Browse files
authored
Implement reconnections with screen (#25)
* Refactor reconnect test to support sub-tests Going to add an alternate screen test next. * Revert "Add reconnecting ptys (#23)" This partially reverts commit 9120171. The new method using screen will not share processes which is a fundamental shift so I think it will be easier to start from scratch. Even though we could keep the UUID check I removed it because it seems cool that you could create your own sessions in the terminal then connect to them in the browser (or vice-versa). * Add test for alternate screen The output test waits for EOF; modify that behavior so we can check that certain strings are not displayed *without* waiting for the timeout. This means to be accurate we should always check for output that should exist after the output that should not exist would have shown up. * Add timeout flag to dev client This makes it easier to test reconnects manually. * Add size to initial connection This way you do not need a subsequent resize and we can have the right size from the get-go. * Prevent prompt from rendering twice in tests * Add Nix flake * Propagate process close error * Implement reconnecting TTY with screen * Encapsulate session logic * Localize session map * Consolidate test scaffolding into helpers I think this helps make the tests a bit more concise. * Test many connections at once * Fix errors not propagating through web socket close Since the server closed the socket the caller has no chance to close with the right code and reason. Also abnormal closure is not a valid close code. * Fix test flake in reading output Without waiting for the copy you can sometimes get "file already closed". I guess process.Wait must have some side effect.
1 parent 4c9c14e commit da995d8

File tree

23 files changed

+985
-399
lines changed

23 files changed

+985
-399
lines changed

README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Error handling is omitted for brevity.
1212

1313
```golang
1414
conn, _, _ := websocket.Dial(ctx, "ws://remote.exec.addr", nil)
15-
defer conn.Close(websocket.StatusAbnormalClosure, "terminate process")
15+
defer conn.Close(websocket.StatusNormalClosure, "normal closure")
1616

1717
execer := wsep.RemoteExecer(conn)
1818
process, _ := execer.Start(ctx, wsep.Command{
@@ -25,18 +25,16 @@ go io.Copy(os.Stderr, process.Stderr())
2525
go io.Copy(os.Stdout, process.Stdout())
2626

2727
process.Wait()
28-
conn.Close(websocket.StatusNormalClosure, "normal closure")
2928
```
3029

3130
### Server
3231

3332
```golang
3433
func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3534
conn, _ := websocket.Accept(w, r, nil)
35+
defer conn.Close(websocket.StatusNormalClosure, "normal closure")
3636

3737
wsep.Serve(r.Context(), conn, wsep.LocalExecer{})
38-
39-
ws.Close(websocket.StatusNormalClosure, "normal closure")
4038
}
4139
```
4240

browser/client.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export interface Command {
1414
}
1515

1616
export type ClientHeader =
17-
| { type: 'start'; command: Command }
17+
| { type: 'start'; id: string; command: Command; cols: number; rows: number; }
1818
| { type: 'stdin' }
1919
| { type: 'close_stdin' }
2020
| { type: 'resize'; cols: number; rows: number };
@@ -42,8 +42,14 @@ export const closeStdin = (ws: WebSocket) => {
4242
ws.send(msg.buffer);
4343
};
4444

45-
export const startCommand = (ws: WebSocket, command: Command) => {
46-
const msg = joinMessage({ type: 'start', command: command });
45+
export const startCommand = (
46+
ws: WebSocket,
47+
command: Command,
48+
id: string,
49+
rows: number,
50+
cols: number
51+
) => {
52+
const msg = joinMessage({ type: 'start', command, id, rows, cols });
4753
ws.send(msg.buffer);
4854
};
4955

ci/alt.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
# Script for testing the alt screen.
3+
4+
# Enter alt screen.
5+
tput smcup
6+
7+
function display() {
8+
# Clear the screen.
9+
tput clear
10+
# Move cursor to the top left.
11+
tput cup 0 0
12+
# Display content.
13+
echo "ALT SCREEN"
14+
}
15+
16+
function redraw() {
17+
display
18+
echo "redrawn"
19+
}
20+
21+
# Re-display on resize.
22+
trap 'redraw' WINCH
23+
24+
display
25+
26+
# The trap will not run while waiting for a command so read input in a loop with
27+
# a timeout.
28+
while true ; do
29+
if read -n 1 -t .1 ; then
30+
# Clear the screen.
31+
tput clear
32+
# Exit alt screen.
33+
tput rmcup
34+
exit
35+
fi
36+
done

ci/fmt.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
2+
23
echo "Formatting..."
34

45
go mod tidy

ci/image/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ FROM golang:1
33
ENV GOFLAGS="-mod=readonly"
44
ENV CI=true
55

6+
RUN apt update && apt install -y screen
7+
68
RUN go install golang.org/x/tools/cmd/goimports@latest
79
RUN go install golang.org/x/lint/golint@latest
810
RUN go install github.com/mattn/goveralls@latest

ci/lint.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22

33
echo "Linting..."
44

client.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ 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
32-
Command string
33-
Args []string
31+
ID string
32+
Command string
33+
Args []string
34+
// Commands with a TTY also require Rows and Cols.
3435
TTY bool
36+
Rows uint16
37+
Cols uint16
3538
Stdin bool
3639
UID uint32
3740
GID uint32
@@ -103,7 +106,7 @@ type remoteProcess struct {
103106
pid int
104107
done chan struct{}
105108
closeErr error
106-
exitCode *int
109+
exitMsg *proto.ServerExitCodeHeader
107110
readErr error
108111
stdin io.WriteCloser
109112
stdout pipe
@@ -267,8 +270,7 @@ func (r *remoteProcess) listen(ctx context.Context) {
267270
r.readErr = err
268271
return
269272
}
270-
271-
r.exitCode = &exitMsg.ExitCode
273+
r.exitMsg = &exitMsg
272274
return
273275
}
274276
}
@@ -319,11 +321,10 @@ func (r *remoteProcess) Wait() error {
319321
if r.readErr != nil {
320322
return r.readErr
321323
}
322-
// when listen() closes r.done, either there must be a read error
323-
// or exitCode is set non-nil, so it's safe to dereference the pointer
324-
// here
325-
if *r.exitCode != 0 {
326-
return ExitError{Code: *r.exitCode}
324+
// when listen() closes r.done, either there must be a read error or exitMsg
325+
// is set non-nil, so it's safe to access members here.
326+
if r.exitMsg.ExitCode != 0 {
327+
return ExitError{code: r.exitMsg.ExitCode, error: r.exitMsg.Error}
327328
}
328329
return nil
329330
}

client_test.go

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,25 @@ func TestRemoteStdin(t *testing.T) {
5151
}
5252
}
5353

54-
func mockConn(ctx context.Context, t *testing.T, options *Options) (*websocket.Conn, *httptest.Server) {
54+
func mockConn(ctx context.Context, t *testing.T, wsepServer *Server, options *Options) (*websocket.Conn, *httptest.Server) {
5555
mockServerHandler := func(w http.ResponseWriter, r *http.Request) {
5656
ws, err := websocket.Accept(w, r, nil)
5757
if err != nil {
5858
w.WriteHeader(http.StatusInternalServerError)
5959
return
6060
}
61-
err = Serve(r.Context(), ws, LocalExecer{}, options)
61+
if wsepServer != nil {
62+
err = wsepServer.Serve(r.Context(), ws, LocalExecer{}, options)
63+
} else {
64+
err = Serve(r.Context(), ws, LocalExecer{}, options)
65+
}
6266
if err != nil {
63-
t.Errorf("failed to serve execer: %v", err)
64-
ws.Close(websocket.StatusAbnormalClosure, "failed to serve execer")
67+
// Max reason string length is 123.
68+
errStr := err.Error()
69+
if len(errStr) > 123 {
70+
errStr = errStr[:123]
71+
}
72+
ws.Close(websocket.StatusInternalError, errStr)
6573
return
6674
}
6775
ws.Close(websocket.StatusNormalClosure, "normal closure")
@@ -79,7 +87,11 @@ func TestRemoteExec(t *testing.T) {
7987
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
8088
defer cancel()
8189

82-
ws, server := mockConn(ctx, t, nil)
90+
wsepServer := NewServer()
91+
defer wsepServer.Close()
92+
defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount())
93+
94+
ws, server := mockConn(ctx, t, wsepServer, nil)
8395
defer server.Close()
8496

8597
execer := RemoteExecer(ws)
@@ -92,14 +104,20 @@ func TestRemoteClose(t *testing.T) {
92104
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
93105
defer cancel()
94106

95-
ws, server := mockConn(ctx, t, nil)
107+
wsepServer := NewServer()
108+
defer wsepServer.Close()
109+
defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount())
110+
111+
ws, server := mockConn(ctx, t, wsepServer, nil)
96112
defer server.Close()
97113

98114
execer := RemoteExecer(ws)
99115
cmd := Command{
100-
Command: "/bin/bash",
116+
Command: "sh",
101117
TTY: true,
102118
Stdin: true,
119+
Cols: 100,
120+
Rows: 100,
103121
Env: []string{"TERM=linux"},
104122
}
105123

@@ -138,14 +156,20 @@ func TestRemoteCloseNoData(t *testing.T) {
138156
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
139157
defer cancel()
140158

141-
ws, server := mockConn(ctx, t, nil)
159+
wsepServer := NewServer()
160+
defer wsepServer.Close()
161+
defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount())
162+
163+
ws, server := mockConn(ctx, t, wsepServer, nil)
142164
defer server.Close()
143165

144166
execer := RemoteExecer(ws)
145167
cmd := Command{
146-
Command: "/bin/bash",
168+
Command: "sh",
147169
TTY: true,
148170
Stdin: true,
171+
Cols: 100,
172+
Rows: 100,
149173
Env: []string{"TERM=linux"},
150174
}
151175

@@ -171,14 +195,20 @@ func TestRemoteClosePartialRead(t *testing.T) {
171195
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
172196
defer cancel()
173197

174-
ws, server := mockConn(ctx, t, nil)
198+
wsepServer := NewServer()
199+
defer wsepServer.Close()
200+
defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount())
201+
202+
ws, server := mockConn(ctx, t, wsepServer, nil)
175203
defer server.Close()
176204

177205
execer := RemoteExecer(ws)
178206
cmd := Command{
179-
Command: "/bin/bash",
207+
Command: "sh",
180208
TTY: true,
181209
Stdin: true,
210+
Cols: 100,
211+
Rows: 100,
182212
Env: []string{"TERM=linux"},
183213
}
184214

@@ -205,7 +235,11 @@ func TestRemoteExecFail(t *testing.T) {
205235
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
206236
defer cancel()
207237

208-
ws, server := mockConn(ctx, t, nil)
238+
wsepServer := NewServer()
239+
defer wsepServer.Close()
240+
defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount())
241+
242+
ws, server := mockConn(ctx, t, wsepServer, nil)
209243
defer server.Close()
210244

211245
execer := RemoteExecer(ws)
@@ -223,9 +257,10 @@ func testExecerFail(ctx context.Context, t *testing.T, execer Execer) {
223257
go io.Copy(ioutil.Discard, process.Stdout())
224258

225259
err = process.Wait()
226-
code, ok := err.(ExitError)
260+
exitErr, ok := err.(ExitError)
227261
assert.True(t, "is exit error", ok)
228-
assert.True(t, "exit code is nonzero", code.Code != 0)
262+
assert.True(t, "exit code is nonzero", exitErr.ExitCode() != 0)
263+
assert.Equal(t, "exit error", exitErr.Error(), "exit status 2")
229264
assert.Error(t, "wait for process to error", err)
230265
}
231266

@@ -239,7 +274,11 @@ func TestStderrVsStdout(t *testing.T) {
239274
stderr bytes.Buffer
240275
)
241276

242-
ws, server := mockConn(ctx, t, nil)
277+
wsepServer := NewServer()
278+
defer wsepServer.Close()
279+
defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount())
280+
281+
ws, server := mockConn(ctx, t, wsepServer, nil)
243282
defer server.Close()
244283

245284
execer := RemoteExecer(ws)

0 commit comments

Comments
 (0)