Skip to content

Commit b28cc74

Browse files
fioncatclaude
andcommitted
fix: reconnect the terminal when its stream drops
The SDK capped envd streams at the HTTP client timeout, which cut off every terminal after five minutes. Bump the SDK to v0.2.1, which lifts that cap. A stream can still drop for other reasons while the shell keeps running in the sandbox, so connect now attaches to the same PTY again, up to five times with exponential backoff, and resizes it afterwards so full-screen programs redraw. A shell that exited while detached ends the session normally. A keystroke that fails to send is dropped instead of silently disabling stdin for the rest of the session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e699308 commit b28cc74

5 files changed

Lines changed: 93 additions & 23 deletions

File tree

‎cmd/sandbox/connect.go‎

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package sandbox
22

33
import (
4+
"context"
45
"fmt"
56
"os"
7+
"time"
68

79
"github.com/spf13/cobra"
810
"github.com/spf13/pflag"
911
"github.com/ucloud/ucloud-sandbox-cli/cmd"
1012
"github.com/ucloud/ucloud-sandbox-cli/cmd/flags"
1113
"github.com/ucloud/ucloud-sandbox-sdk-go/pkg/api"
14+
"github.com/ucloud/ucloud-sandbox-sdk-go/pkg/errdefs"
1215
"github.com/ucloud/ucloud-sandbox-sdk-go/pkg/sandbox/commands"
1316
"github.com/ucloud/ucloud-sandbox-sdk-go/pkg/sandbox/pty"
1417
"golang.org/x/term"
@@ -113,46 +116,113 @@ func connectTerminal(ctx cmd.OperationContext, sbx *api.Sandbox, user string, op
113116
Rows: rows,
114117
Cols: cols,
115118
}
116-
handle, err := envd.Pty().Create(ctx, ptySize, opts)
119+
terminal := envd.Pty()
120+
handle, err := terminal.Create(ctx, ptySize, opts)
117121
if err != nil {
118122
return fmt.Errorf("failed to create pty: %w", err)
119123
}
124+
// Stdin, resizes and reconnects all address the shell by its PID, which
125+
// stays the same across reconnects.
126+
pid := handle.PID
120127

121128
oldState, err := term.MakeRaw(fd)
122129
if err != nil {
123130
return err
124131
}
125132
defer term.Restore(fd, oldState)
126133

127-
// Forward PTY output to stdout.
128-
go func() {
129-
for data := range handle.Output() {
130-
os.Stdout.Write(data)
131-
}
132-
}()
133-
134-
// Forward stdin to PTY.
134+
// Forward stdin to PTY. A keystroke that fails to send, while the stream
135+
// is being re-established say, is dropped rather than ending the session.
135136
go func() {
136137
buf := make([]byte, 256)
137138
for {
138139
n, err := os.Stdin.Read(buf)
139140
if err != nil {
140141
return
141142
}
142-
if err := handle.SendStdin(ctx, buf[:n]); err != nil {
143+
if err := terminal.SendStdin(ctx, pid, buf[:n]); err != nil && ctx.Err() != nil {
143144
return
144145
}
145146
}
146147
}()
147148

148-
stopResize := watchTerminalResize(ctx, fd, handle, cols, rows)
149+
resize := func(ctx context.Context, size pty.Size) error {
150+
return terminal.Resize(ctx, pid, size)
151+
}
152+
stopResize := watchTerminalResize(ctx, fd, resize, cols, rows)
149153
defer stopResize()
150154

151-
// The shell's own exit code is not the CLI's: a session ended by "exit 1"
152-
// is still a session that ran.
153-
if _, err := handle.Wait(ctx); err != nil {
154-
return err
155+
for {
156+
// Forward PTY output to stdout. Draining it here rather than in a
157+
// goroutine keeps output from an old stream ahead of a new one's.
158+
for data := range handle.Output() {
159+
os.Stdout.Write(data)
160+
}
161+
162+
// The shell's own exit code is not the CLI's: a session ended by
163+
// "exit 1" is still a session that ran.
164+
_, err := handle.Wait(ctx)
165+
if err == nil || ctx.Err() != nil {
166+
return err
167+
}
168+
169+
// The stream dropped but the shell may well still be running in the
170+
// sandbox, so attach to it again rather than end the session.
171+
handle, err = reconnectTerminal(ctx, terminal, pid, err)
172+
if err != nil {
173+
return err
174+
}
175+
if handle == nil {
176+
return nil
177+
}
178+
179+
// Nothing printed while detached is replayed. A resize makes
180+
// full-screen programs redraw, so they at least come back intact.
181+
if cols, rows, err := term.GetSize(fd); err == nil {
182+
_ = resize(ctx, pty.Size{Cols: cols, Rows: rows})
183+
}
155184
}
185+
}
156186

157-
return nil
187+
// Reconnect attempts after a terminal's stream drops, and the delay before the
188+
// first; each later attempt waits twice as long as the one before.
189+
const (
190+
maxReconnectAttempts = 5
191+
reconnectBaseDelay = 500 * time.Millisecond
192+
)
193+
194+
// reconnectTerminal attaches to the terminal pid again after its stream failed
195+
// with cause. It returns a nil handle, and no error, when the terminal turns
196+
// out to have exited meanwhile.
197+
func reconnectTerminal(ctx context.Context, terminal *pty.Pty, pid int, cause error) (*pty.Handle, error) {
198+
delay := reconnectBaseDelay
199+
for attempt := 1; attempt <= maxReconnectAttempts; attempt++ {
200+
// The terminal is in raw mode, so a bare "\n" would not return the
201+
// cursor to the start of the line.
202+
fmt.Fprintf(os.Stderr, "\r\nTerminal connection lost (%v), reconnecting (%d/%d)...\r\n", cause, attempt, maxReconnectAttempts)
203+
204+
select {
205+
case <-ctx.Done():
206+
return nil, ctx.Err()
207+
case <-time.After(delay):
208+
}
209+
delay *= 2
210+
211+
handle, err := terminal.Connect(ctx, pid, commands.Options{})
212+
if err == nil {
213+
fmt.Fprint(os.Stderr, "Terminal reconnected\r\n")
214+
return handle, nil
215+
}
216+
if errdefs.IsNotFound(err) {
217+
// The shell exited while no stream was attached, so its exit was
218+
// never seen; there is nothing left to reconnect to.
219+
return nil, nil
220+
}
221+
if ctx.Err() != nil {
222+
return nil, ctx.Err()
223+
}
224+
cause = err
225+
}
226+
227+
return nil, fmt.Errorf("failed to reconnect to the terminal after %d attempts: %w", maxReconnectAttempts, cause)
158228
}

‎cmd/sandbox/connect_resize_unix.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"golang.org/x/term"
1313
)
1414

15-
func watchTerminalResize(ctx context.Context, fd int, handle *pty.Handle, _, _ int) func() {
15+
func watchTerminalResize(ctx context.Context, fd int, resize func(context.Context, pty.Size) error, _, _ int) func() {
1616
sigCh := make(chan os.Signal, 1)
1717
done := make(chan struct{})
1818
signal.Notify(sigCh, syscall.SIGWINCH)
@@ -26,7 +26,7 @@ func watchTerminalResize(ctx context.Context, fd int, handle *pty.Handle, _, _ i
2626
return
2727
case <-sigCh:
2828
if cols, rows, err := term.GetSize(fd); err == nil {
29-
_ = handle.Resize(ctx, pty.Size{Cols: cols, Rows: rows})
29+
_ = resize(ctx, pty.Size{Cols: cols, Rows: rows})
3030
}
3131
}
3232
}

‎cmd/sandbox/connect_resize_windows.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"golang.org/x/term"
1111
)
1212

13-
func watchTerminalResize(ctx context.Context, fd int, handle *pty.Handle, cols, rows int) func() {
13+
func watchTerminalResize(ctx context.Context, fd int, resize func(context.Context, pty.Size) error, cols, rows int) func() {
1414
done := make(chan struct{})
1515
go func() {
1616
ticker := time.NewTicker(500 * time.Millisecond)
@@ -27,7 +27,7 @@ func watchTerminalResize(ctx context.Context, fd int, handle *pty.Handle, cols,
2727
if err != nil || (newCols == cols && newRows == rows) {
2828
continue
2929
}
30-
if err := handle.Resize(ctx, pty.Size{Cols: newCols, Rows: newRows}); err == nil {
30+
if err := resize(ctx, pty.Size{Cols: newCols, Rows: newRows}); err == nil {
3131
cols, rows = newCols, newRows
3232
}
3333
}

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ require (
1212
github.com/spf13/cobra v1.10.2
1313
github.com/spf13/pflag v1.0.9
1414
github.com/stretchr/testify v1.12.1
15-
github.com/ucloud/ucloud-sandbox-sdk-go v0.1.1-0.20260924125206-321014f4cd75
15+
github.com/ucloud/ucloud-sandbox-sdk-go v0.2.1
1616
golang.org/x/mod v0.40.0
1717
golang.org/x/term v0.44.0
1818
google.golang.org/protobuf v1.36.12

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
6666
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
6767
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
6868
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
69-
github.com/ucloud/ucloud-sandbox-sdk-go v0.1.1-0.20260924125206-321014f4cd75 h1:x9bAfhx7/v+QrWVjT3bI7AmlT+70r7m/yRnRSH3iyQ0=
70-
github.com/ucloud/ucloud-sandbox-sdk-go v0.1.1-0.20260924125206-321014f4cd75/go.mod h1:yAGdMxnXt1xmVwH+wzsYkF0iobYei+M3aU8AGPD1DsA=
69+
github.com/ucloud/ucloud-sandbox-sdk-go v0.2.1 h1:YeoZhWqvgSkVpLUMFkpB4fE4k84685MHqlY5zyvN8po=
70+
github.com/ucloud/ucloud-sandbox-sdk-go v0.2.1/go.mod h1:yAGdMxnXt1xmVwH+wzsYkF0iobYei+M3aU8AGPD1DsA=
7171
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
7272
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
7373
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=

0 commit comments

Comments
 (0)