Skip to content

fix(sdk): send WebSocket keepalive pings on PTY sessions - #319

Merged
MDzaja merged 1 commit into
mainfrom
fix/pty-websocket-keepalive
Oct 5, 2026
Merged

MDzaja merged 1 commit into
mainfrom
fix/pty-websocket-keepalive

Conversation

@MDzaja

@MDzaja MDzaja commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A PTY session can legitimately stay silent for a long time — a build or test run that produces no output for 20–60 minutes. During that window the WebSocket carries no traffic at all, and intermediate proxies / load balancers between the client and the sandbox close connections they consider idle. The shell keeps running inside the sandbox, but the client loses the stream and has to reconnect with connect_pty_session; output produced between the drop and the reconnect is not replayed.

Before this change only the sync Python SDK sent pings (via the httpx-ws library default). Every other SDK sent nothing on an idle PTY.

Change

Every SDK now sends a WebSocket ping frame every 20 s on PTY sessions. Ping/pong are control frames: they keep the connection active for every hop on the path without injecting any input into the terminal.

SDK Mechanism
Python (sync) explicit keepalive_ping_interval_seconds / keepalive_ping_timeout_seconds on the two PTY connect_ws calls (makes the previous library default explicit)
Python (async) heartbeat= on the shared _open_ws opener (also covers log streaming sockets, which have the same idle problem)
TypeScript setInterval(ws.ping) when the socket exposes ping() (Node ws); cleared on close/error, unref'd. Browser WebSocket cannot send pings, so it is skipped there
Go keepalive goroutine using WriteControl(PingMessage) (safe alongside WriteMessage), stops when the session ends
Java client.newBuilder().pingInterval(20s) for the PTY socket (shares pool/dispatcher with the base client)
Ruby keepalive thread sending :ping frames; input writes and pings are serialized with a mutex; stopped on close/disconnect

The interval (20 s) sits comfortably below common LB idle timeouts (60 s+) and matches the pre-existing httpx-ws default.

Tests

  • Python: tests/test_process_keepalive.py asserts keepalive kwargs on sync connect_ws and heartbeat on async ws_connect
  • TypeScript: fake-timer tests that ping() fires every 20 s while open, stops after close, and is never scheduled on sockets without ping()
  • Go: TestPtyHandleSendsKeepalivePings — httptest ws server counts pings, verifies they stop after Disconnect() (-race clean)
  • Ruby: spec with a stubbed interval verifies pings while open and none after disconnect
  • Java: existing PtyHandleTest passes against the ping-enabled client

Verified per SDK: pytest + pylint + basedpyright, jest + eslint + prettier, go test -race + golangci-lint, rspec + rubocop, gradle test.


Summary by cubic

Prevents idle PTY sessions from being dropped by proxies and load balancers by sending WebSocket ping frames every 20 seconds from every SDK. Previously only the sync Python SDK sent pings; the rest sent nothing during long silent runs, so clients lost the stream and output produced between the drop and reconnect was not replayed. The interval sits below common LB idle timeouts and matches the pre-existing httpx-ws default.

SDK Mechanism
Python (sync) explicit keepalive_ping_interval_seconds / keepalive_ping_timeout_seconds on PTY connect_ws calls
Python (async) heartbeat= on the shared _open_ws opener, which also covers log streaming sockets
TypeScript setInterval(ws.ping) on Node ws; skipped in browsers, which cannot send pings
Go keepalive goroutine using WriteControl(PingMessage), stopped when the session ends
Java OkHttp pingInterval on the PTY socket
Ruby keepalive thread sending :ping frames, serialized with input writes

Tests

  • Added keepalive verification per SDK: pytest/jest fake timers, go test -race, rspec, and existing gradle tests.

Written for commit 2b03fcf. Summary will update on new commits.

Review in cubic

Verified against a live environment

Python SDK from this branch, three PTY sessions in parallel, each running sleep 1500; echo DONE; exit (25 minutes with zero output):

Session Keepalive Outcome
sync SDK (httpx-ws) ping every 20 s (74 pings) DONE received after 1530 s, exit code 0
async SDK (aiohttp heartbeat=20) ping every ~20 s DONE received after 1531 s, exit code 0
control: async opened with heartbeat=None (pre-PR behaviour) none server finished, but the client never received DONE or a close frame: is_connected() stayed True and wait() hung indefinitely

The control case shows the pre-PR failure mode is worse than a clean drop: an intermediate hop silently discards the idle connection and the client is left with a half-open socket. With pings, the path stays active, and if it does die a missed pong surfaces as a close within the ping interval instead of a hang.

@vidoc-agent

vidoc-agent Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Vidoc security review

Tip

Good to merge — no security issues found. Reviewed 11 changed files.

💬 Have questions? Tag @vidoc in a comment and I'll answer.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread sdk-ruby/spec/daytona/pty_handle_spec.rb
A PTY that stays silent for a long time (a build or test run producing
no output for tens of minutes) carries no traffic, and intermediate
proxies/load balancers close idle WebSockets. The shell keeps running
inside the sandbox but the client loses the stream and must reconnect;
output produced in between is not replayed.

Send a ping frame every 20s from every SDK so the connection never
looks idle:

- python: explicit httpx-ws keepalive on sync PTY connects; aiohttp
  heartbeat on the shared async WebSocket opener
- typescript: setInterval(ws.ping) on runtimes whose socket supports
  it (Node ws); browsers cannot send pings and are skipped
- go: keepalive goroutine using WriteControl(PingMessage)
- java: OkHttp pingInterval on the PTY client
- ruby: keepalive thread sending :ping frames, serialized with input
  writes

Signed-off-by: MDzaja <mirkodzaja0@gmail.com>
@MDzaja
MDzaja force-pushed the fix/pty-websocket-keepalive branch from d6aa71a to 2b03fcf Compare October 5, 2026 13:44
@MDzaja

MDzaja commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 5, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@MDzaja I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 11 files

Re-trigger cubic

@MDzaja
MDzaja merged commit 1fa9471 into main Oct 5, 2026
28 checks passed
@MDzaja
MDzaja deleted the fix/pty-websocket-keepalive branch October 5, 2026 15:07
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant