test(lsp): cover the select-based read-body timeout branch via os.pipe()
CI / lint (pull_request) Successful in 39s
CI / quality (pull_request) Successful in 51s
CI / typecheck (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 32s
CI / helm (pull_request) Successful in 37s
CI / security (pull_request) Successful in 1m34s
CI / push-validation (pull_request) Successful in 31s
CI / unit_tests (pull_request) Successful in 4m38s
CI / docker (pull_request) Successful in 1m54s
CI / integration_tests (pull_request) Successful in 8m35s
CI / coverage (pull_request) Successful in 9m1s
CI / status-check (pull_request) Successful in 3s

The DoS mitigation added in db389a730 wraps each message-body read in
``select()`` so a stalled client cannot pin the server forever. The
existing transport tests route through MockLspTransport, whose
``BytesIO`` raises ``UnsupportedOperation`` on ``fileno()`` -- so
``_read_body_with_timeout`` always falls through the BytesIO fast
path and the actual ``select()``-based mitigation code (the part
that runs in production) is never executed. That left ~19 new lines
uncovered, dragging total coverage below the 96.5% floor and
failing the coverage gate.

Two new scenarios drive the helper through an ``os.pipe()`` whose
read fd satisfies ``fileno()``, so ``use_select`` is True and the
real DoS-protection path runs:

* ``timeout=0.0`` makes the deadline already past on the first
  iteration, exercising the ``if timeout <= 0:`` early-exit warning.
* ``timeout=0.05`` lets ``select.select()`` run and time out with no
  ready descriptors, exercising the ``if not ready:`` warning.

Both paths log ``lsp.transport.read_timeout`` and return ``None``,
matching the production behaviour the helper was added to provide.

ISSUES CLOSED: #5566
This commit is contained in:
2026-06-05 17:25:35 -04:00
parent c3de4921bb
commit 22c3cddf08
2 changed files with 82 additions and 0 deletions
+12
View File
@@ -483,3 +483,15 @@ Feature: LSP server stub JSON-RPC protocol
When I check the MESSAGE_READ_TIMEOUT constant
Then the MESSAGE_READ_TIMEOUT should be 30
And MESSAGE_READ_TIMEOUT should be importable from lsp server module
@lsp_transport_edge @lsp_dos_protection
Scenario: Read body times out immediately with read_timeout zero on a real pipe
When I invoke read body with timeout 0.0 on an empty pipe expecting 10 bytes
Then the read body result should be None
And a transport read_timeout warning should be captured
@lsp_transport_edge @lsp_dos_protection
Scenario: Read body times out when select reports no data on a real pipe
When I invoke read body with timeout 0.05 on an empty pipe expecting 10 bytes
Then the read body result should be None
And a transport read_timeout warning should be captured
+70
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import contextlib
import io
import json
import os
from collections.abc import Generator
from typing import Any
@@ -807,3 +808,72 @@ def step_message_read_timeout_importable(context: Context) -> None:
assert hasattr(_lsp_server_module, "MESSAGE_READ_TIMEOUT"), (
"MESSAGE_READ_TIMEOUT attribute not found in lsp.server module"
)
# ---------------------------------------------------------------------------
# Real-pipe timeout coverage for ``_read_body_with_timeout``
# ---------------------------------------------------------------------------
# The MockLspTransport uses ``BytesIO`` whose ``fileno()`` raises
# ``UnsupportedOperation``, so the DoS mitigation's ``select()`` guard is
# never exercised by transport tests routed through the mock. These steps
# wire a real ``os.pipe()`` into the server so the actual timeout code path
# (the one that protects production from partial-body stalls) runs end to end.
@when(
"I invoke read body with timeout {timeout:f} on an empty pipe expecting "
"{expected:d} bytes"
)
def step_read_body_pipe_no_data(
context: Context, timeout: float, expected: int
) -> None:
"""Exercise ``_read_body_with_timeout``'s select-based timeout path.
A pipe fd makes ``fileno()`` succeed so ``use_select`` is True and the
DoS-protection branch runs. No bytes are written before the call:
* ``timeout=0.0`` makes the deadline already past on entry, exercising
the ``if timeout <= 0:`` early-exit warning.
* A small positive timeout (e.g. ``0.05``) lets ``select.select()`` run
and return no ready descriptors, exercising the ``if not ready:``
warning.
Both paths log ``lsp.transport.read_timeout`` and return ``None``.
"""
read_fd, write_fd = os.pipe()
pipe_in = os.fdopen(read_fd, "rb", buffering=0)
server = LspServer(
input_stream=pipe_in,
output_stream=io.BytesIO(),
read_timeout=timeout,
)
try:
with _capture_structlogs() as captured:
result = server._read_body_with_timeout(expected)
context.lsp_read_body_result = result
context.lsp_captured_logs = list(captured)
finally:
os.close(write_fd)
pipe_in.close()
@then("the read body result should be None")
def step_read_body_result_none(context: Context) -> None:
"""Assert the timeout path returned ``None`` (so the caller can ``_SKIP``)."""
assert context.lsp_read_body_result is None, (
f"expected None, got {context.lsp_read_body_result!r}"
)
@then("a transport read_timeout warning should be captured")
def step_transport_read_timeout_captured(context: Context) -> None:
"""Assert at least one ``lsp.transport.read_timeout`` event was logged."""
matches = [
entry
for entry in context.lsp_captured_logs
if entry.get("event") == "lsp.transport.read_timeout"
]
assert matches, (
"no lsp.transport.read_timeout warning in captured logs: "
f"{context.lsp_captured_logs}"
)