From 8065ab087a6ebfb6175ffb7252ce8011c9efde74 Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:25:49 -0400 Subject: [PATCH 1/3] OCPBUGS-83817: Fix orphaned shell processes in pod terminal on WebSocket disconnect When a terminal WebSocket connection drops, the shell process inside the container was never terminated, leading to accumulation of orphaned processes. This occurred because gorilla/websocket's Close() only drops the TCP connection without sending a WebSocket close frame, and no exit command was sent to the shell. Send "exit" to the exec STDIN channel and a proper WebSocket close frame to the Kubernetes API server when the proxy connection closes. Use a dedicated backendWriteMutex to prevent concurrent writes to the backend connection, and check the negotiated subprotocol to use the correct frame encoding. --- pkg/proxy/proxy.go | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 36b5e9176b0..7df42231aa1 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -256,7 +256,18 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, errMsg, statusCode) return } - defer backend.Close() + isExec := strings.HasSuffix(r.URL.Path, "/exec") + var backendWriteMutex sync.Mutex // Protects backend writes from copyMsgs and deferred exec cleanup + defer func() { + if isExec { + backendWriteMutex.Lock() + sendExecExitCommand(backend) + backendWriteMutex.Unlock() + } + closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") + _ = backend.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(websocketTimeout)) + backend.Close() + }() upgrader := &websocket.Upgrader{ Subprotocols: []string{subProtocol}, @@ -295,7 +306,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Can't just use io.Copy here since browsers care about frame headers. go func() { errc <- copyMsgs(nil, frontend, backend) }() - go func() { errc <- copyMsgs(&writeMutex, backend, frontend) }() + go func() { errc <- copyMsgs(&backendWriteMutex, backend, frontend) }() for { select { @@ -314,6 +325,31 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// sendExecExitCommand sends "exit\r" to the exec session's STDIN channel +// to terminate the shell process and prevent orphaned processes when the +// frontend WebSocket disconnects. +func sendExecExitCommand(backend *websocket.Conn) { + var msg []byte + var msgType int + + switch backend.Subprotocol() { + case "base64.channel.k8s.io": + exitCmd := base64.StdEncoding.EncodeToString([]byte("exit\r")) + msg = []byte("0" + exitCmd) + msgType = websocket.TextMessage + case "v4.channel.k8s.io", "v5.channel.k8s.io": + msg = append([]byte{0}, []byte("exit\r")...) + msgType = websocket.BinaryMessage + default: + klog.V(4).Infof("Skipping exec exit command for unsupported websocket subprotocol: %q", backend.Subprotocol()) + return + } + + if err := backend.WriteMessage(msgType, msg); err != nil { + klog.V(4).Infof("Failed to send exit command to exec session: %v", err) + } +} + func copyMsgs(writeMutex *sync.Mutex, dest, src *websocket.Conn) error { for { messageType, msg, err := src.ReadMessage() From 035c269bde89cd3ad8dce76c3104880262c89b93 Mon Sep 17 00:00:00 2001 From: Leo Li <36619969+Leo6Leo@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:06:40 -0400 Subject: [PATCH 2/3] Update pkg/proxy/proxy.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- pkg/proxy/proxy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 7df42231aa1..4722bcded1b 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -256,7 +256,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, errMsg, statusCode) return } - isExec := strings.HasSuffix(r.URL.Path, "/exec") + isExec := strings.HasSuffix(strings.TrimRight(r.URL.Path, "/"), "/exec") var backendWriteMutex sync.Mutex // Protects backend writes from copyMsgs and deferred exec cleanup defer func() { if isExec { From b7032bfe05e6141f1fde59dcb3fa5fd4bc06fb2d Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:17:47 -0400 Subject: [PATCH 3/3] fix: apply coderabbit's proposal --- pkg/proxy/proxy.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 4722bcded1b..81f922f37b0 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -261,7 +261,9 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func() { if isExec { backendWriteMutex.Lock() + _ = backend.SetWriteDeadline(time.Now().Add(websocketTimeout)) sendExecExitCommand(backend) + _ = backend.SetWriteDeadline(time.Time{}) backendWriteMutex.Unlock() } closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") @@ -361,7 +363,9 @@ func copyMsgs(writeMutex *sync.Mutex, dest, src *websocket.Conn) error { err = dest.WriteMessage(messageType, msg) } else { writeMutex.Lock() + _ = dest.SetWriteDeadline(time.Now().Add(websocketTimeout)) err = dest.WriteMessage(messageType, msg) + _ = dest.SetWriteDeadline(time.Time{}) writeMutex.Unlock() }