Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLI
Assigneesstack72

Relationships

#843 Make serve WebSocket idle/keepalive timeout configurable (untunable default aborts runs when serve's loop briefly blocks)

Opened by swamp_lord · 6/26/2026· Shipped 6/27/2026

Problem

swamp serve closes a connection's WebSocket when Deno's WS keepalive gets no pong in time, and there is no way to tune the ping interval or pong timeout — no serve flag, no workflow run flag, no SWAMP_* env (workflow run --timeout is only a run cancellation deadline). serve calls Deno.upgradeWebSocket(req) with NO options (src/cli/commands/serve.ts:898/902), so the liveness behavior is Deno's hardcoded default.

The failure mode: whenever serve's single JS event loop is blocked longer than that timeout, it can't process incoming pong frames on ANY connection, so Deno's keepalive fires on all of them at the same instant and serve closes them:

serve·connection  WebSocket error: "No response from ping frame."   (×N — every live connection, same millisecond)

Because the run is coupled to the connection, the in-flight placed methods then fail (Cannot push to a closed AsyncQueue), the run aborts mid-flight, and downstream teardown is skipped so provisioned resources leak (in our case 7 worker containers, reaped by hand).

TWO CORRECTIONS to earlier versions of this report:

  1. An early draft blamed client-side CPU starvation / worker cold-start spikes. Wrong — the peers (workers + client) were healthy and ponging; serve simply couldn't read them while its own loop was blocked. The host was a 12-core box, orchestrator uncapped, load ~5.5 — not CPU-bound.

  2. A later draft blamed deno bundle: it claimed serve rebuilt the fan-out type's ship-bundle once per instance (~8.4s each) because bundleSourceFactory is memoized per model-definition. That is ALSO wrong, on both counts. Verified directly: (a) deno bundle of the real extension (a 113-line file that transitively imports a 3,565-line model + its full dep graph) is 88ms warm, not 8.4s; (b) bundleSourceFactory is memoized per type (bundlePromise ??= in model_kind_adapter.ts at the registerExtensionType / promoteFromLazy site), so N instances of one type bundle exactly once. Bundling is not what blocks the loop.

What actually blocked the loop (~60–70s in our case): a forEach fanned out to 7 model instances, all dispatched at the same instant — but serve resolved each step's model definition serially, ~10s apart, back-to-back, with no yield in between. The per-step model·method·run … Found model … events land like clockwork at +10.2s, +9.8s, +9.9s, +9.95s, +9.97s, +9.86s. 7 × ~10s ≈ 70s of contiguous loop occupancy > the keepalive window, so at the 60s mark every connection (7 workers + the client) dropped in the same millisecond.

The ~10s is consumed BEFORE each "Found model" line — i.e. in the definition resolution itself: execution_service.tsfindDefinitionByIdOrNamedefinitionRepo.findByNameGlobal(name) (model_lookup.ts:131). That global definition lookup appears to be O(store) per call and runs on serve's single event loop, so a fan-out of N steps serializes into N × lookup-cost of uninterrupted loop time. (We have not micro-profiled findByNameGlobal itself — the ~10s/step is measured end-to-end from the run log — but the gap is squarely in the resolve-definition step, not in bundling or in worker execution.)

The specific slow path is a separate concern worth its own issue; the point for THIS issue is that any multi-second serve-loop occupancy — a serial fan-out resolve, a large dispatch prep, GC — silently kills every live connection because the keepalive is un-tunable AND nothing yields the loop to service pings during the block.

Proposed Solution

Make the WebSocket liveness timeout explicit and configurable (defaults preserved):

  • The minimal fix: pass an explicit idleTimeout to the two Deno.upgradeWebSocket(req) calls (serve.ts:898/902) instead of inheriting Deno's default, and make it configurable:
    • swamp serve --ws-idle-timeout <duration> (env SWAMP_WS_IDLE_TIMEOUT).
  • Optionally split into --ws-ping-interval / --ws-pong-timeout if finer control is wanted.

Raising the timeout lets a transiently-blocked serve loop recover without dropping healthy connections, and lets operators confirm the behavior (set it generous, re-run, watch the long fan-out survive).

NOTE: a generous timeout is a BACKSTOP, not the whole fix. The deeper fixes are orthogonal and both worth doing: (1) stop occupying the loop for N × lookup — resolve fan-out step definitions concurrently and/or make findByNameGlobal not O(store) per call; (2) decouple the run from the connection so a transient drop can't abort an in-flight run (Lab #519). But a configurable keepalive is valuable independently: serve should not abort a run because its own loop was briefly busy.

Affected Components

  • serve WebSocket setup — pass an explicit, configurable idleTimeout to Deno.upgradeWebSocket (serve.ts:898/902) instead of relying on the runtime default.
  • swamp serve flag + env plumbing.
  • Docs — list the new flag/env alongside the existing serve options.

Why It Matters

serve's event loop will occasionally be busy for several seconds (serial fan-out definition resolution, large dispatch prep, GC). With an un-tunable, runtime-default keepalive, any such pause silently drops every connected worker AND the client at once, fails the in-flight methods, and leaks whatever the run provisioned. Long-running self-hosted fan-out workflows — exactly what serve + remote execution exist for — are the most exposed. A configurable timeout makes the system resilient to its own brief stalls.

  • The tactical complement to detached / resumable runs (Lab #519): #519 removes the run↔connection coupling so a drop can't abort a run; this makes the drop far less likely in the first place.
  • The serial fan-out definition-resolve cost (~10s/step via findByNameGlobal on the serve loop) is the trigger we hit and deserves its own performance issue; this issue is specifically about the keepalive being un-tunable so that ANY loop stall is fatal.
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW+ 3 MOREPR_MERGED+ 1 MORECONTRIBUTOR_NOTIFIED

Shipped

6/27/2026, 12:28:39 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack726/26/2026, 11:29:55 PM
Editable. Press Enter to edit.

stack72 commented 6/27/2026, 12:29:24 AM

Thanks @swamp_lord for reporting this! The fix has been merged and a release is on its way. We appreciate your contribution to swamp.

Sign in to post a ripple.