Skip to content

Reject unbounded WebSocket queue-size overrides #286

Description

@Justinabox

Summary

The WebSocket feed intends to bound each slow client's queue (_WEBSOCKET_QUEUE_MAXSIZE = 32), but the supported application override app["callstack_ws_queue_size"] is converted with int(...) without range validation. Setting it to 0 creates asyncio.Queue(maxsize=0), which asyncio defines as unbounded. A stalled WebSocket client can then accumulate an unbounded in-process envelope backlog.

Root cause

In server.py:304-305, the endpoint does:

queue_size = int(request.app.get("callstack_ws_queue_size", _WEBSOCKET_QUEUE_MAXSIZE))
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=queue_size)

asyncio.Queue treats maxsize <= 0 as an infinite queue. _enqueue_websocket_envelope() only drops the oldest item after queue.full() is true, which never occurs for that queue configuration. The current overflow test covers a directly constructed bounded queue but not the endpoint configuration boundary.

Reproduction

Baseline main is healthy:

$ git diff --check
# exit 0

$ PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
1093 passed in 7.67s

No hardware or network service is required. A no-hardware aiohttp.test_utils probe replaces server.asyncio.Queue with a recording subclass, creates the app, sets app["callstack_ws_queue_size"] = 0, then opens /ws and reads its hello envelope. Observed output:

configured_queue_size=0
queue_maxsize_seen=[0]
queue_is_unbounded=True

Therefore the endpoint instantiates an unbounded queue, bypassing its explicit slow-client memory guard.

Impact

An operator or integration that configures a zero/negative queue size to mean "disabled" or makes a type-conversion mistake silently removes the per-client memory bound. Under a high event rate and a slow/disconnected client, this can cause memory growth in the HTTP process. Serialized envelopes are PII-safe, but unbounded process memory is still an availability risk for unattended Raspberry Pi deployments.

This is distinct from closed #228, which bounds EventBus stream queues, and from #213, which proposes bounded replay cursors. It is the runtime configuration boundary of the existing live WebSocket queue.

Suggested fix direction

Validate the queue size before the WebSocket upgrade/subscription is established:

  • accept only a non-boolean positive integer (or explicitly document a bounded supported range);
  • reject 0, negatives, booleans, non-numeric strings, and non-finite numeric inputs with a deterministic configuration error or safe HTTP 500/400 policy;
  • keep the default value and the current drop-oldest, PII-safe overflow notice behavior unchanged.

Prefer a small helper so the server-level configuration contract is directly unit-testable.

Acceptance criteria

  • app["callstack_ws_queue_size"] = 0 and negative values cannot create an unbounded queue.
  • Boolean, non-integer, and malformed override values fail predictably without creating a WebSocket subscription.
  • The normal default still creates a queue with maxsize 32 (or its documented replacement).
  • A valid positive override creates a bounded queue and retains drop-oldest/overflow behavior.
  • Tests cover the endpoint/configuration boundary, not only _enqueue_websocket_envelope() in isolation.
  • Existing WebSocket auth, event filtering, serialization/redaction, and multi-client behavior remain green.

Verification gates

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_websocket.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Duplicate check

Searched open/closed issues and PRs for callstack_ws_queue_size, WebSocket queue maxsize zero, WebSocket slow client queue bound, and queue size configuration PII websocket. The only adjacent work found was closed #228 (EventBus stream bounds) and open #213 (replay cursors); neither tracks an invalid live WebSocket queue override creating asyncio.Queue(maxsize=0).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions