From 0d4bb6269d70efa689c4758ef35288943b138458 Mon Sep 17 00:00:00 2001 From: Brendan Burns <5751682+brendandburns@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:57:40 +0000 Subject: [PATCH 1/3] Add optional websocket keepalive pings for idle Exec sessions --- src/exec.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/exec_test.ts | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/exec.ts b/src/exec.ts index 090802b31fe..10bcf18e6d8 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -7,6 +7,10 @@ import { KubeConfig } from './config.js'; import { isResizable, ResizableStream, TerminalSizeQueue } from './terminal-size-queue.js'; import { WebSocketHandler, WebSocketInterface } from './web-socket-handler.js'; +export interface ExecOptions { + pingIntervalMs?: number; +} + export class Exec { public 'handler': WebSocketInterface; @@ -39,6 +43,7 @@ export class Exec { stdin: stream.Readable | null, tty: boolean, statusCallback?: (status: V1Status) => void, + options?: ExecOptions, ): Promise { const query = { stdout: stdout != null, @@ -60,6 +65,10 @@ export class Exec { } return true; }); + const pingIntervalMs = options?.pingIntervalMs; + if (pingIntervalMs !== undefined && Number.isInteger(pingIntervalMs) && pingIntervalMs > 0) { + this.setupPing(conn, pingIntervalMs); + } if (stdin != null) { WebSocketHandler.handleStandardInput(conn, stdin, WebSocketHandler.StdinStream); } @@ -70,4 +79,35 @@ export class Exec { } return conn; } + + private setupPing(conn: WebSocket.WebSocket, pingIntervalMs: number): void { + const socket = conn as WebSocket.WebSocket & { + ping?: () => void; + on?: (event: string, listener: (...args: unknown[]) => void) => void; + removeListener?: (event: string, listener: (...args: unknown[]) => void) => void; + }; + if (typeof socket.ping !== 'function') { + return; + } + + let awaitingPong = false; + const timer = setInterval(() => { + if (conn.readyState === WebSocket.OPEN) { + if (!awaitingPong) { + awaitingPong = true; + socket.ping!(); + } + } + }, pingIntervalMs); + const onPong = () => { + awaitingPong = false; + }; + const clearKeepAlive = () => { + clearInterval(timer); + socket.removeListener?.('pong', onPong); + }; + socket.on?.('pong', onPong); + socket.on?.('close', clearKeepAlive); + socket.on?.('error', clearKeepAlive); + } } diff --git a/src/exec_test.ts b/src/exec_test.ts index 405558b0c11..2df5ef1a74d 100644 --- a/src/exec_test.ts +++ b/src/exec_test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'node:test'; -import { deepStrictEqual, strictEqual } from 'node:assert'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import { setTimeout as setTimeoutPromise } from 'node:timers/promises'; import WebSocket from 'isomorphic-ws'; import { ReadableStreamBuffer, WritableStreamBuffer } from 'stream-buffers'; import { anyFunction, anything, capture, instance, mock, verify, when } from 'ts-mockito'; @@ -156,5 +157,44 @@ describe('Exec', () => { await closePromise; verify(fakeWebSocket.close()).called(); }); + + it('should optionally send websocket pings', async () => { + const pingIntervalMs = 5; + const waitForPingsMs = 20; + const kc = new KubeConfig(); + const pingHandlers: Record void> = {}; + let pingCount = 0; + const fakeConn = { + readyState: WebSocket.OPEN, + ping: () => { + pingCount++; + }, + on: (event: string, listener: () => void) => { + pingHandlers[event] = listener; + }, + } as unknown as WebSocket.WebSocket; + const ws: WebSocketInterface = { + connect: async () => fakeConn, + }; + const exec = new Exec(kc, ws); + + await exec.exec('ns', 'pod', 'container', 'command', null, null, null, false, undefined, { + pingIntervalMs, + }); + await setTimeoutPromise(waitForPingsMs); + + strictEqual(pingCount, 1); + strictEqual(typeof pingHandlers.pong, 'function'); + pingHandlers.pong(); + await setTimeoutPromise(waitForPingsMs); + ok(pingCount > 1); + + strictEqual(typeof pingHandlers.close, 'function'); + pingHandlers.close(); + const pingCountAtClose = pingCount; + await setTimeoutPromise(waitForPingsMs); + + strictEqual(pingCount, pingCountAtClose); + }); }); }); From be80d110cbbc42ec6a66a16f3e1c08ba2ef4efac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:23:14 +0000 Subject: [PATCH 2/3] exec: throw on invalid pingIntervalMs instead of silently ignoring --- src/exec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/exec.ts b/src/exec.ts index 10bcf18e6d8..5b3180e0d29 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -66,7 +66,10 @@ export class Exec { return true; }); const pingIntervalMs = options?.pingIntervalMs; - if (pingIntervalMs !== undefined && Number.isInteger(pingIntervalMs) && pingIntervalMs > 0) { + if (pingIntervalMs !== undefined) { + if (!Number.isInteger(pingIntervalMs) || pingIntervalMs <= 0) { + throw new Error('pingIntervalMs must be a positive integer'); + } this.setupPing(conn, pingIntervalMs); } if (stdin != null) { From 2575ea902e531c091d41ef3095b9cc0dfbb33a98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:25:41 +0000 Subject: [PATCH 3/3] Guard websocket listener registration --- src/exec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/exec.ts b/src/exec.ts index 5b3180e0d29..62a59e38c88 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -109,8 +109,10 @@ export class Exec { clearInterval(timer); socket.removeListener?.('pong', onPong); }; - socket.on?.('pong', onPong); - socket.on?.('close', clearKeepAlive); - socket.on?.('error', clearKeepAlive); + if (typeof socket.on === 'function') { + socket.on('pong', onPong); + socket.on('close', clearKeepAlive); + socket.on('error', clearKeepAlive); + } } }