Skip to content

Commit 5e24490

Browse files
committed
fix(shell): improve reliability — keepalive, error classification, agent-timeout detection
- Replace unbounded read deadline with ping/pong keepalive (PingInterval=30s, ReadTimeout=75s) - Classify WebSocket close codes: 1007/1008 permanent (cancel+no retry), 1011 transient (retry) - Detect agent-side timeout messages within 1011 errors (gateway 90s deadline, K8s exec/port-forward 45s) and show specific "retrying" warning rather than generic "service unavailable" - Stop reconnect loop on permanent close errors (permission denied, auth rejected) - Fix port-forward: replace log.Fatal with log.Errorf+return, improve error messages - Add wserror.go with IsPermanentCloseError, IsInternalServerError, IsAgentResponseTimeout, ServiceUnavailableMessage helpers and full test coverage
1 parent 15439bf commit 5e24490

4 files changed

Lines changed: 257 additions & 16 deletions

File tree

pkg/port-forward.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,25 @@ func handleConnection(con net.Conn, req *PortForwardRequest) {
116116
log.Error("error closing connection: ", err)
117117
}
118118
fmt.Printf("Connection closed from %s => %d\n", con.RemoteAddr().String(), req.Port)
119-
var e *websocket.CloseError
120-
if errors.As(errRet, &e) && e.Code != websocket.CloseNormalClosure {
121-
log.Error("connection terminated badly with ", e)
119+
if IsPermanentCloseError(errRet) {
120+
log.Error("Port-forward connection rejected: check your permissions or run 'qovery auth'")
121+
} else if IsAgentResponseTimeout(errRet) {
122+
log.Warnf("Port-forward timed out (agent could not reach the pod or set up the forward). Reconnect to try again.")
123+
} else if IsInternalServerError(errRet) {
124+
log.Warnf("%s Reconnect to try again.", ServiceUnavailableMessage("Port-forward"))
125+
} else if errRet != nil {
126+
var e *websocket.CloseError
127+
if !errors.As(errRet, &e) || e.Code != websocket.CloseNormalClosure {
128+
log.Error("Port-forward connection terminated: ", errRet)
129+
}
122130
}
123131
}()
124132

125133
wsConn, err := mkWebsocketConn(req)
126134
if err != nil {
127-
log.Fatal("error while creating websocket connection", err)
135+
errRet = err
136+
log.Errorf("error while creating websocket connection: %v", err)
137+
return
128138
}
129139
defer func() {
130140
if err := wsConn.ws.Close(); err != nil {

pkg/shell.go

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import (
2424
const StdinBufferSize = 4096
2525
const ReconnectDelay = 5 * time.Second
2626
const PingInterval = 30 * time.Second
27-
const ReadTimeout = 60 * time.Second
27+
28+
// ReadTimeout must be > 2 × PingInterval so that a healthy connection always receives a pong
29+
// before the deadline fires. The pong handler resets the deadline on every pong received.
30+
const ReadTimeout = 75 * time.Second
2831

2932
type TerminalSize interface {
3033
SetTtySize(width uint16, height uint16)
@@ -104,7 +107,7 @@ func ExecShell(req TerminalSize, path string) {
104107

105108
done := make(chan struct{})
106109
wg.Add(1)
107-
go readWebsocketConnection(ctx, wsConn, currentConsole, done, &normalExit, &wg)
110+
go readWebsocketConnection(ctx, cancel, wsConn, currentConsole, done, &normalExit, &wg)
108111

109112
pingTicker := time.NewTicker(PingInterval)
110113

@@ -144,7 +147,9 @@ func ExecShell(req TerminalSize, path string) {
144147
}
145148

146149
// Do NOT close stdIn — readUserConsole owns it and it is used across reconnects.
147-
time.Sleep(ReconnectDelay)
150+
if ctx.Err() == nil && !normalExit.Load() && !userCancelled.Load() {
151+
time.Sleep(ReconnectDelay)
152+
}
148153
}
149154

150155
wg.Wait()
@@ -173,7 +178,7 @@ func createWebsocketConn(req interface{}, path string) (*websocket.Conn, error)
173178
return conn, err
174179
}
175180

176-
func readWebsocketConnection(ctx context.Context, wsConn *websocket.Conn, currentConsole console.Console, done chan struct{}, normalExit *atomic.Bool, wg *sync.WaitGroup) {
181+
func readWebsocketConnection(ctx context.Context, cancel context.CancelFunc, wsConn *websocket.Conn, currentConsole console.Console, done chan struct{}, normalExit *atomic.Bool, wg *sync.WaitGroup) {
177182
defer wg.Done()
178183

179184
var once sync.Once
@@ -189,6 +194,16 @@ func readWebsocketConnection(ctx context.Context, wsConn *websocket.Conn, curren
189194
}
190195
defer safeClose()
191196

197+
// Set an initial read deadline. The pong handler refreshes it on every
198+
// pong so that idle-but-healthy sessions are not torn down; only truly
199+
// dead connections (no pong for ReadTimeout) are detected and closed.
200+
_ = wsConn.SetReadDeadline(time.Now().Add(ReadTimeout))
201+
// SetReadDeadline failure in the pong handler would surface as a ReadMessage error on
202+
// the next iteration, but cannot happen on a healthy net.Conn.
203+
wsConn.SetPongHandler(func(string) error {
204+
return wsConn.SetReadDeadline(time.Now().Add(ReadTimeout))
205+
})
206+
192207
for {
193208
select {
194209
case <-ctx.Done():
@@ -197,16 +212,24 @@ func readWebsocketConnection(ctx context.Context, wsConn *websocket.Conn, curren
197212
msgType, msg, err := wsConn.ReadMessage()
198213
if err != nil {
199214
var e *websocket.CloseError
200-
if errors.As(err, &e) {
201-
if e.Code == websocket.CloseNormalClosure {
202-
log.Info("** shell terminated bye **")
203-
normalExit.Store(true)
204-
} else {
205-
log.Errorf("connection closed by server: %v", e)
206-
}
215+
if !errors.As(err, &e) {
216+
log.Errorf("error while reading on websocket: %v", err)
207217
return
208218
}
209-
log.Errorf("error while reading on websocket: %v", err)
219+
switch {
220+
case e.Code == websocket.CloseNormalClosure:
221+
log.Info("** shell terminated bye **")
222+
normalExit.Store(true)
223+
case e.Code == 1007 || e.Code == 1008: // same as IsPermanentCloseError
224+
log.Errorf("Shell connection rejected: check your permissions or run 'qovery auth'")
225+
cancel()
226+
case IsAgentResponseTimeout(err): // must come before generic 1011 branch
227+
log.Warnf("Shell session timed out while the agent was preparing your connection. Retrying...")
228+
case e.Code == 1011:
229+
log.Warnf("%s Retrying...", ServiceUnavailableMessage("Shell"))
230+
default:
231+
log.Errorf("connection closed by server: %v", e)
232+
}
210233
return
211234
}
212235

pkg/wserror.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package pkg
2+
3+
import (
4+
"errors"
5+
"strings"
6+
7+
"github.com/gorilla/websocket"
8+
)
9+
10+
// IsPermanentCloseError returns true if the websocket close error should NOT
11+
// be retried (permission denied, auth/policy violation).
12+
// Transient errors (abnormal closure, going away, internal server error) return false.
13+
func IsPermanentCloseError(err error) bool {
14+
var closeErr *websocket.CloseError
15+
if !errors.As(err, &closeErr) {
16+
return false
17+
}
18+
switch closeErr.Code {
19+
case 1007: // Invalid frame payload data — used by gateway for permission errors
20+
return true
21+
case 1008: // Policy Violation — used for auth/token errors
22+
return true
23+
default:
24+
return false
25+
}
26+
}
27+
28+
// IsInternalServerError returns true if the websocket close error is code 1011 (Internal Error).
29+
func IsInternalServerError(err error) bool {
30+
var closeErr *websocket.CloseError
31+
if !errors.As(err, &closeErr) {
32+
return false
33+
}
34+
return closeErr.Code == 1011
35+
}
36+
37+
// IsAgentResponseTimeout returns true if the websocket close error indicates
38+
// that K8s operations on the shell-agent side timed out, or that the gateway
39+
// timed out waiting for the agent to respond. All are transient and resolve
40+
// once the pod's Kubernetes exec API is responsive again.
41+
//
42+
// IsAgentResponseTimeout is a strict subset of IsInternalServerError (both match close code 1011).
43+
// Always check IsAgentResponseTimeout before IsInternalServerError, otherwise the specific timeout
44+
// message is swallowed by the generic 1011 branch.
45+
//
46+
// Matched substrings and their sources:
47+
// - "exceeded for receiving agent response" — gateway wait (shell_gateway.rs DEFAULT_AGENT_RESPONSE_TIMEOUT)
48+
// - "while connecting to pod" — shell-agent K8s exec timeout (shell.rs KUBE_OPERATION_TIMEOUT)
49+
// - "while setting up port forward" — shell-agent K8s port-forward timeout (port_forward.rs KUBE_PORT_FORWARD_TIMEOUT)
50+
func IsAgentResponseTimeout(err error) bool {
51+
var closeErr *websocket.CloseError
52+
if !errors.As(err, &closeErr) {
53+
return false
54+
}
55+
if closeErr.Code != 1011 {
56+
return false
57+
}
58+
return strings.Contains(closeErr.Text, "exceeded for receiving agent response") ||
59+
strings.Contains(closeErr.Text, "while connecting to pod") ||
60+
strings.Contains(closeErr.Text, "while setting up port forward")
61+
}
62+
63+
// ServiceUnavailableMessage returns a user-friendly message when the cluster agent is unreachable.
64+
func ServiceUnavailableMessage(feature string) string {
65+
return feature + " is not available. Please verify that the cluster hosting this service is running and healthy."
66+
}

pkg/wserror_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package pkg
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
9+
"github.com/gorilla/websocket"
10+
)
11+
12+
func TestIsAgentResponseTimeout(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
err error
16+
want bool
17+
}{
18+
{
19+
name: "1011 gateway wait timeout",
20+
err: &websocket.CloseError{Code: 1011, Text: "Deadline of 90s exceeded for receiving agent response"},
21+
want: true,
22+
},
23+
{
24+
name: "1011 shell-agent K8s exec timeout",
25+
err: &websocket.CloseError{Code: 1011, Text: "Timed out after 45s while connecting to pod"},
26+
want: true,
27+
},
28+
{
29+
name: "1011 shell-agent K8s port-forward timeout",
30+
err: &websocket.CloseError{Code: 1011, Text: "Timed out after 45s while setting up port forward"},
31+
want: true,
32+
},
33+
{
34+
name: "1011 with different reason falls through to IsInternalServerError",
35+
err: &websocket.CloseError{Code: 1011, Text: "some other internal error"},
36+
want: false,
37+
},
38+
{
39+
name: "wrong close code",
40+
err: &websocket.CloseError{Code: 1007, Text: "exceeded for receiving agent response"},
41+
want: false,
42+
},
43+
{
44+
name: "non-websocket error",
45+
err: errors.New("plain network error"),
46+
want: false,
47+
},
48+
{
49+
name: "wrapped 1011 gateway timeout",
50+
err: fmt.Errorf("read failed: %w", &websocket.CloseError{Code: 1011, Text: "Deadline of 90s exceeded for receiving agent response"}),
51+
want: true,
52+
},
53+
}
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
if got := IsAgentResponseTimeout(tt.err); got != tt.want {
57+
t.Errorf("IsAgentResponseTimeout() = %v, want %v", got, tt.want)
58+
}
59+
})
60+
}
61+
}
62+
63+
// TestIsAgentResponseTimeoutBeforeIsInternalServerError verifies that a 1011 close error with
64+
// a timeout message matches BOTH IsAgentResponseTimeout (true) and IsInternalServerError (true),
65+
// since timeout is a strict subset of 1011. The test documents why IsAgentResponseTimeout must
66+
// always be checked first in the error-handling chain — otherwise the specific timeout message
67+
// is swallowed by the generic 1011 branch.
68+
func TestIsAgentResponseTimeoutBeforeIsInternalServerError(t *testing.T) {
69+
for _, text := range []string{
70+
"Deadline of 90s exceeded for receiving agent response",
71+
"Timed out after 45s while connecting to pod",
72+
"Timed out after 45s while setting up port forward",
73+
} {
74+
err := &websocket.CloseError{Code: 1011, Text: text}
75+
if !IsAgentResponseTimeout(err) {
76+
t.Errorf("IsAgentResponseTimeout(%q) = false, want true", text)
77+
}
78+
if !IsInternalServerError(err) {
79+
t.Errorf("IsInternalServerError(%q) = false, want true (timeout is a subset of 1011)", text)
80+
}
81+
}
82+
}
83+
84+
func TestIsInternalServerError(t *testing.T) {
85+
tests := []struct {
86+
name string
87+
err error
88+
want bool
89+
}{
90+
{"1011 matches", &websocket.CloseError{Code: 1011, Text: "anything"}, true},
91+
{"1007 does not match", &websocket.CloseError{Code: 1007, Text: ""}, false},
92+
{"non-websocket error", errors.New("plain error"), false},
93+
{"wrapped 1011", fmt.Errorf("wrap: %w", &websocket.CloseError{Code: 1011, Text: "x"}), true},
94+
}
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
if got := IsInternalServerError(tt.err); got != tt.want {
98+
t.Errorf("IsInternalServerError() = %v, want %v", got, tt.want)
99+
}
100+
})
101+
}
102+
}
103+
104+
func TestIsPermanentCloseError(t *testing.T) {
105+
tests := []struct {
106+
name string
107+
err error
108+
want bool
109+
}{
110+
{"1007 is permanent", &websocket.CloseError{Code: 1007}, true},
111+
{"1008 is permanent", &websocket.CloseError{Code: 1008}, true},
112+
{"1011 is transient", &websocket.CloseError{Code: 1011}, false},
113+
{"1000 is transient", &websocket.CloseError{Code: 1000}, false},
114+
{"non-websocket error", errors.New("plain error"), false},
115+
{"wrapped 1008", fmt.Errorf("wrap: %w", &websocket.CloseError{Code: 1008}), true},
116+
}
117+
for _, tt := range tests {
118+
t.Run(tt.name, func(t *testing.T) {
119+
if got := IsPermanentCloseError(tt.err); got != tt.want {
120+
t.Errorf("IsPermanentCloseError() = %v, want %v", got, tt.want)
121+
}
122+
})
123+
}
124+
}
125+
126+
func TestServiceUnavailableMessage(t *testing.T) {
127+
for _, feature := range []string{"Shell", "Port-forward"} {
128+
msg := ServiceUnavailableMessage(feature)
129+
if !strings.HasPrefix(msg, feature) {
130+
t.Errorf("ServiceUnavailableMessage(%q): expected prefix %q, got: %q", feature, feature, msg)
131+
}
132+
if !strings.Contains(msg, "cluster") {
133+
t.Errorf("ServiceUnavailableMessage(%q): expected 'cluster' in message, got: %q", feature, msg)
134+
}
135+
if !strings.Contains(msg, "running") {
136+
t.Errorf("ServiceUnavailableMessage(%q): expected 'running' in message, got: %q", feature, msg)
137+
}
138+
if !strings.HasSuffix(msg, ".") {
139+
t.Errorf("ServiceUnavailableMessage(%q): expected message to end with '.', got: %q", feature, msg)
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)