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 When I check the MESSAGE_READ_TIMEOUT constant
Then the MESSAGE_READ_TIMEOUT should be 30 Then the MESSAGE_READ_TIMEOUT should be 30
And MESSAGE_READ_TIMEOUT should be importable from lsp server module 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 contextlib
import io import io
import json import json
import os
from collections.abc import Generator from collections.abc import Generator
from typing import Any 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"), ( assert hasattr(_lsp_server_module, "MESSAGE_READ_TIMEOUT"), (
"MESSAGE_READ_TIMEOUT attribute not found in lsp.server module" "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}"
)