Read upgraded streams through the buffered reader - #3438
Open
fruch wants to merge 1 commit into
Open
Conversation
The daemon answers an attach or exec start with the response headers and then writes the stream on the same connection. http.client parses those headers through a buffered reader, which reads up to a whole buffer at a time, so the first frames of the stream can land in that buffer together with the headers. _read_from_socket() reads from the socket instead, so those frames are never seen: exec_run() returns empty output, or output that starts at the second frame. Read through the buffered reader when there is one, which covers the unix, tcp, https and npipe transports as well as ssh with shell-out. read() cannot wait on a buffered reader the way it waits on a socket: buffered bytes do not show up in a poll of the file descriptor, so the wait would block until more data arrived. Skip the wait there and use read1(), which returns what is buffered and only reads the descriptor once the buffer is empty - the same contract as recv(). Plain read() would hold back a frame that is complete but shorter than n bytes, which stalls a stream that stays open. With the wait gone, the socket timeout would end a quiet stream, so disable it as the other streaming helpers already do. Fixes docker#3332 Fixes docker#2042 Signed-off-by: Israel Fruchter <fruch@scylladb.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Read upgraded streams through the buffered reader
Fixes #3332
Fixes #2042
The bug
attachandexec startare HTTP upgrades: the daemon answers with101 UPGRADEDplus headers, and then writes the stream frames on the sameconnection.
http.clientparses those headers throughsock.makefile("rb")— anio.BufferedReader— andreadline()fills thatbuffer up to
io.DEFAULT_BUFFER_SIZEat a time. So whenever the headers andthe first frames arrive close enough together to be returned by one
recv(),the frames end up in the reader's buffer.
APIClient._read_from_socket()then reads from the raw socket, which hasnothing left on it. The frames are simply gone. From the reporter's
stracein #3332:
The symptom is
exec_run()returningb''with exit code0, or outputthat starts at the second frame. It is timing dependent, so it shows up on
whatever widens the window — an SSH-forwarded socket, a loaded host, a remote
daemon — and disappears if you add a
sleepto the command. #2042 is thesame bug reported in 2018.
The behaviour dates to 76ed9c3 (2016), when the API moved to HTTP upgrade
and the client started reading the raw socket. It has been wrong for every
transport since.
Reproducing it deterministically only needs the headers and the first frame in
one write; the tests below do that.
The fix
_read_from_socket()handsframes_iter()the buffered reader instead of thesocket when there is one. That is the whole of the change in
docker/api/client.py, and it covers the unix, tcp, https and npipetransports plus ssh with
use_ssh_client=True.Three things follow from that, and all of them are part of the fix rather
than tidying:
1. Do not poll a buffered reader.
read()waits on the descriptor beforeevery read. Bytes sitting in the reader's buffer are invisible to that poll,
so the wait would block until more data arrived — turning the lost output
into a hang. Verified:
2. Use
read1(n), notread(n).BufferedReader.read(n)blocks until ithas n bytes or hits EOF;
read1(n)returns what is buffered and otherwiseperforms a single read — the contract
read()already has withrecv(n).With
read(n), a stream that stays open delivers nothing until 4096 bytespile up. Against a server that writes one frame and keeps the connection
open:
That is #3333's remaining problem, and it is why this is a separate patch
rather than a review comment. It affects
attach(stream=True)andexec_run(stream=True, tty=True). The existing integration tests do not catchit, because cancelling the stream shuts the socket down and the buffered bytes
are flushed at EOF;
test_stream_ttybelow does catch it.3. Disable the socket timeout. With the poll gone, the blocking read is
the socket's own, so
timeoutnow bounds it — a quietexeclonger thantimeoutseconds would raiseTimeoutErrorwhere it used to waitindefinitely in
poll()._read_from_socket()is the only streaming helperthat did not call
_disable_socket_timeout(); it does now, matching_stream_raw_result()and_multiplexed_response_stream_helper(). Verifiedwith a 2 s client timeout against a stream that stays quiet for 4 s:
TimeoutError: timed outwithout the call,b'hello\n'with it.test_stream_quietcovers this._is_pipe_ended()keeps the npipePIPE_ENDED-means-EOF handling working nowthat the reader, not the
NpipeSocket, is whatread()sees.Tests
TCPSocketStreamUpgradeTestintests/unit/api_test.py. The existingTCPSocketStreamTestsleeps 0.2 s between the headers and the payload, whichis exactly the case that works — the new class writes them with a single
wfile.write()instead, so http.client reads them into one buffer.All six pass with the patch. Five fail on
main, and the sixth guards theregression that skipping the poll would otherwise introduce:
maintest_no_stream_ttyassert b'' == b'hello\noh no\n'test_no_stream_no_ttyassert b'' == b'hello\noh no\n'test_no_stream_no_tty_demuxassert (None, None) == (b'hello\n', b'oh no\n')test_stream_no_ttyassert b'oh no\n' == b'hello\n'test_stream_ttyassert b'oh no\n' == b'hello\n'assert b'hello\noh no\n' == b'hello\n'test_stream_quietTimeoutError: timed outEach test runs on a thread with a deadline, because on unpatched code the read
blocks in
poll()forever rather than failing.Not covered
Two cases share the root cause and are deliberately left alone to keep this
reviewable:
use_ssh_client=False(the defaultDockerClientpath).paramiko'sChannelFilebuffers the same way, but it is not anio.BufferedReaderand exposes no supported way to read its buffer withoutblocking, or a
read1(). Itsread(size)blocks untilsizebytes, likeBufferedReader.read.attach_socket()andexec_start(socket=True), which hand_get_raw_response_socket()to the caller. Callers write to that socket(interactive exec), so a read-only buffered reader cannot be substituted; it
would need a small duplex wrapper.
Relationship to #3333
@antontornqvist found the root cause and wrote #3333, open and unreviewed
since May 2025. The diagnosis there is right, and this patch keeps its shape.
It adds the three things above:
read1()instead ofread(), the sockettimeout, npipe
PIPE_ENDED, and tests that reproduce the issue. Happy forthis to land as a review on #3333 instead if that is easier.
Verification
tests/unit: 617 passed. Three failures on this machine(
test_set_auth_headers_with_dict_and_no_auth_configsreads the local~/.docker/config.json, two datetime tests are timezone dependent) failidentically on
main.tests/integration/api_exec_test.py: 21 passed against Docker 29.8.0.tests/integration/api_container_test.py,tests/integration/models_containers_test.py: 119 passed, includingtest_attach_stream_and_cancelandtest_logs_streaming_and_follow_and_cancel.Three failures (legacy container links and
MacAddress, both removed inDocker 29; a missing
websocket-client) fail identically onmain.ruff==0.1.8 docker tests: clean.