Skip to content

fix: stream archives instead of holding them in memory, and close a leaked socket - #27

Merged
tas50 merged 1 commit into
mainfrom
fix/stream-archives
Aug 24, 2026
Merged

tas50 merged 1 commit into
mainfrom
fix/stream-archives

Conversation

@tas50

@tas50 tas50 commented Aug 24, 2026

Copy link
Copy Markdown
Member

1. The build context was assembled entirely in memory

Tar.pack_directory wrote the whole archive into a StringIO before sending a byte. A build context is whatever the caller points at — a Rails app with its assets, a monorepo subtree, a directory holding a model checkpoint — so its full size was charged to the heap on top of what the daemon was about to receive.

It now packs to a Tempfile and streams. The connection layer already sends a readable body chunked, so the archive never needs to be resident.

pack_dockerfile still uses StringIO — a short generated Dockerfile has no reason to touch disk.

2. archive_in slurped the IO it was given

body: archive.respond_to?(:read) ? archive.read : archive,

That defeats the point of accepting an IO — a container filesystem is exactly the kind of archive nobody wants in memory. It is handed over as it stands now.

3. The fix both of those depend on

attach_payload chose between body_stream and body with a class check:

when IO, StringIO

which looks exhaustive and is not. Tempfile is a delegator around File, so Tempfile.new.is_a?(IO) is false. A packed context would have fallen through to body = payload and gone out as the delegator's to_s — a string like #<Tempfile:0x...> where the daemon expected a tar.

What Net::HTTP actually needs from a body stream is read, so that is what is asked for. There is a test asserting the request contains real archive bytes and the word Tempfile appears nowhere in it.

4. A failed TLS handshake leaked the socket

sync_close only ties the SSLSocket to the socket underneath once the SSLSocket exists and owns it. An expired certificate, a hostname mismatch or an untrusted CA raised before that, and dial converted it to a ConnectionError with nothing closing the descriptor — so a retry loop waiting for a daemon to come up exhausts file descriptors rather than failing cleanly.

The test stands up a TCP server that never speaks TLS and asserts the socket the transport opened is closed after the failure.

Note on the allowlist

spec/zero_dependency_spec.rb (added in #19) caught require "tempfile" immediately, which is the guard doing its job. tempfile is a default gem, not a bundled one — confirmed by loading it with GEM_HOME/GEM_PATH empty — so it is safe for a gem that declares no runtime dependencies, and it is allowlisted explicitly rather than by loosening the rule.

Verification

  • bundle exec rake — 333 runs, 1128 assertions, 0 failures
  • steep check clean, chefstyle clean
  • DOCKER_API_NG_INTEGRATION=1 rake integration — 19 runs, 49 assertions, 0 failures against a real daemon, which is the run that matters here: it puts a genuinely streamed tar through /build and /containers/{id}/archive.
  • spec/archive_streaming_spec.rb — 2 failures and 2 errors on main.

…eaked socket

Tar.pack_directory built the whole build context in a StringIO before
sending a byte. A context is whatever the caller points at -- a Rails app
with its assets, a monorepo subtree, a directory holding a model
checkpoint -- so the archive's full size was charged to the heap on top of
what the daemon was about to receive. It is written to a temporary file
now, and streamed.

Container#archive_in did `archive.respond_to?(:read) ? archive.read :
archive`, which defeated the point of accepting an IO: a container
filesystem is exactly the kind of archive nobody wants resident. The IO is
handed over as it stands.

Both of those depend on a third fix. attach_payload chose between
body_stream and body with `when IO, StringIO`, which looks exhaustive and
is not -- Tempfile is a delegator around File, so Tempfile.new.is_a?(IO)
is false. A packed context would have fallen through to `body = payload`
and gone out as the delegator's to_s: a string like "#<Tempfile:...>"
where the daemon expected a tar. What Net::HTTP needs from a body stream
is `read`, so that is what is asked for now.

Separately, Transport::Tls leaked the TCP socket when the handshake
failed. sync_close only ties the two together once an SSLSocket exists and
owns it, so an expired certificate, a hostname mismatch or an untrusted CA
left the descriptor open until GC -- and a retry loop waiting for a daemon
to come up exhausts descriptors rather than failing cleanly.

tempfile joins the allowlist in spec/zero_dependency_spec.rb. It is a
default gem, not a bundled one, so it is safe for a gem that declares no
runtime dependencies -- verified by loading it with no gems on the path.

Signed-off-by: Tim Smith <tim@mondoo.com>
@tas50
tas50 merged commit 3815ac9 into main Aug 24, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant