Skip to content

fix(docker): read upgraded exec streams through the buffered reader - #15984

Merged
dimakr merged 2 commits into
scylladb:masterfrom
scylla-zeus-bot:maia-fix/SCT-952
Sep 10, 2026
Merged

dimakr merged 2 commits into
scylladb:masterfrom
scylla-zeus-bot:maia-fix/SCT-952

Conversation

@scylla-zeus-bot

@scylla-zeus-bot scylla-zeus-bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

What

A docker exec can return empty output with exit code 0, and SCT then uses that empty output as a real value. One effect is that an empty mktemp result becomes the path in container.put_archive(path=""). The daemon refuses every write with 400 Bad Request: bad parameter: path cannot be empty, the 300 s retry budget in sdcm/wait.py runs out, and the run fails with NodeSetupFailed while SCT writes scylla.yaml into the container. See artifacts-docker-fips-test build 496.

Root cause

The daemon answers an exec start with 101 UPGRADED headers and then writes the output frames on the same connection. http.client parses those headers through a BufferedReader that reads up
to 8 KiB at a time, so a frame arriving in the same recv() lands in that buffer. docker-py skips the buffer and reads from the socket, so it never sees the frame.

This is docker/docker-py#3332, also reported as #2042. The upstream fix is docker/docker-py#3333, open and unreviewed since May 2025, and docker-py 7.2.0 still carries the old code. This change mirrors it, so it can be dropped in one piece once a release includes it.

It is not a recent regression, and nothing is specific to mktemp. Any DockerCmdRunner.run() call whose output SCT parses can hit it, and so can the other exec_run() callers, such as the gcloud container in sdcm/utils/gce_utils.py. The window is small, so the failure is rare, and a busy runner widens it.

Fix

  • sdcm/utils/docker_utils.py: BufferedStreamAPIClient gives docker-py's frame reader the buffer instead of the socket, and docker.utils.socket.read reads a buffer without polling
    the socket first. docker-py still does the framing, the demux and the response close. SCT's DockerClient builds its api object from the subclass, so every container SCT talks to is
    covered. The ssh and named-pipe transports keep something else in that attribute and still go through docker-py.
  • Skipping that poll is part of the fix. A frame already in the buffer would otherwise wait for traffic on an idle socket, so a streamed line would arrive only when the next line does.
  • sdcm/remote/remote_file.py: raise when the mktemp result is empty, instead of retrying an unusable destination for 300 s. This also covers the ssh transport, where the change above
    deliberately leaves docker-py in charge.
  • sdcm/remote/docker_cmd_runner.py: build the extraction directory with PurePosixPath. A path inside a container is POSIX whatever the host runs.

Fixes SCT-952

Testing

@github-actions github-actions Bot added the P2 High Priority label Sep 7, 2026
@scylladb-promoter

scylladb-promoter commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

❌ Test Summary: FAILED

✅ Precommit: PASSED

Total Passed Failed Skipped
44 20 0 24

❌ Tests: FAILED

Total Passed Failed Errors Skipped
6742 6685 2 0 55

Failed Tests

integration.test_events.TestSctEventsIntegration.test_default_filters_non_gce_backend_keeps_bind_race_as_error

Type: FAILURE
Message: AssertionError: assert 'Could not start Prometheus API server' in ''

Traceback
self = <unit_tests.integration.test_events.TestSctEventsIntegration object at 0x7dc4ba474c30>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7dc499d47cb0>

    @pytest.mark.integration
    def test_default_filters_non_gce_backend_keeps_bind_race_as_error(self, monkeypatch):
        """The GCE-specific first-boot bind-race filter must not apply on other backends."""
        monkeypatch.setattr(events_setup, "SkipPerIssues", lambda *args, **kwargs: True)
    
        with environment(SCT_CLUSTER_BACKEND="docker"):
            enable_default_filters(SCTConfiguration())
    
        with self.wait_for_n_events(self.get_events_logger(), count=1):
            DatabaseLogEvent.DATABASE_ERROR().add_info(
                node="A",
                line_number=22,
                line="2026-06-27T03:17:49.539Z some-non-gce-node !ERR | scylla[1669] "
                "[shard 0:strm] init - Could not start Prometheus API server on 10.128.0.47:9180: "
                "std::system_error (error system:99, posix_listen failed for address 10.128.0.47:9180: "
                "Cannot assign requested address)",
            ).publish()
    
        error_log_content = self.get_event_log_file("error.log")
>       assert "Could not start Prometheus API server" in error_log_content
E       AssertionError: assert 'Could not start Prometheus API server' in ''

unit_tests/integration/test_events.py:145: AssertionError

integration.test_external_backtrace_service.test_decode_via_external_service_returns_symbols

Type: FAILURE
Message: ValueError: External backtrace service failed: Traceback (most recent call last): File "/code/backtrace/tasks/backtrace.py", line 78, in decode_backtrace async with extract_file(build_id, task) as cache_path: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.12/contextlib.py", line 210, in __aenter__ return await anext(self.gen) ^^^^^^^^^^^^^^^^^^^^^ File "/code/backtrace/tasks/__init__.py", line 56, in extract_file cache_path.mkdir(parents=True, exist_ok=True) File "/usr/local/lib/python3.12/pathlib.py", line 1311, in mkdir os.mkdir(self, mode) OSError: [Errno 28] No space left on device: '/app/cache/42315'

Traceback
node = <unit_tests.lib.fake_cluster.DummyNode object at 0x78f5b963e120>
known_build_id = '57e6772414554e9e359c113522c308778060325c'

    def test_decode_via_external_service_returns_symbols(node, known_build_id):
        """Verify _decode_via_external_service returns decoded symbols for a known build."""
>       result = node._decode_via_external_service(known_build_id, SAMPLE_BACKTRACE_ADDRESSES)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

unit_tests/integration/test_external_backtrace_service.py:72: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <unit_tests.lib.fake_cluster.DummyNode object at 0x78f5b963e120>
build_id = '57e6772414554e9e359c113522c308778060325c'
raw_backtrace = '0x4a3c9b7\n0x11ddd33\n0x11ddcec'

    @lru_cache(maxsize=None)
    def _decode_via_external_service(self, build_id: str, raw_backtrace: str) -> str:
        """Decode backtrace using the external backtraces.scylladb.com service.
    
        Avoids loading 1+ GB DWARF debug info on the monitor node (prevents OOM).
    
        Args:
            build_id: hex build ID of the scylla binary
            raw_backtrace: raw backtrace string (newline-separated addresses)
    
        Returns:
            decoded backtrace string (stdout from the service)
    
        Raises:
            requests.RequestException: on network/HTTP error
            ValueError: if the service returns success=false
        """
        response = requests.post(
            "https://backtrace.scylladb.com/api/backtrace",
            json={"build_id": build_id, "input": "Backtrace:\n" + raw_backtrace},
            timeout=120,
        )
        response.raise_for_status()
        result = response.json()
        if not result.get("success"):
>           raise ValueError(f"External backtrace service failed: {result.get('stderr', 'unknown error')}")
E           ValueError: External backtrace service failed: Traceback (most recen

Full build log

@fruch fruch added test-provision-docker Run provision test on Docker (Scylla) docker-backend labels Sep 7, 2026
@fruch fruch added the test-integration Enable running the integration tests suite label Sep 7, 2026
remote_file() passed the 'mktemp' result to send_files() without checking it. An
empty result made every write fail, and wait.wait_for() then retried it for 300s
before the run ended as NodeSetupFailed. It now raises at once and names the real
problem.

send_files() also built the extraction directory with pathlib.Path, which reads a
path with the rules of the host. A path inside a container is POSIX whatever the
host runs, so it uses PurePosixPath now.
A docker exec can return empty output with exit code 0. The daemon sends the
'101 UPGRADED' headers and the first output frame together. http.client keeps
that frame in its read buffer. docker-py skips the buffer and reads from the
socket, so it never sees the frame.
SCT then uses the empty output as a real value. One effect is that an empty
'mktemp' result can become the path in container.put_archive(path=""). The daemon
then refuses writes with '400 ... path cannot be empty' until the 300s wait ends
as NodeSetupFailed.
This is a known upstream docker/docker-py#3332 issue.

The change fixes this by adding BufferedStreamAPIClient that gives docker-py
frame reader the buffer instead of the socket, and docker.utils.socket.read reads
a buffer without polling the socket first. Without that skipped poll a buffered
line waits for the next one.
@dimakr dimakr changed the title fix(docker-remoter): reject empty destination path in send_files (SCT-952) fix(docker): read upgraded exec streams through the buffered reader Sep 7, 2026
@fruch
fruch marked this pull request as ready for review September 8, 2026 11:22
@fruch
fruch requested review from dimakr and fruch as code owners September 8, 2026 11:22
@fruch

fruch commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@dimakr the docker and integration tests fails on known issue

I'll try getting the docker py issue fixed (with clearer PR for it)

@dimakr
dimakr merged commit 8cbc7d8 into scylladb:master Sep 10, 2026
29 of 31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted docker-backend P2 High Priority promoted-to-master test-integration Enable running the integration tests suite test-provision-docker Run provision test on Docker (Scylla)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants