Skip to content

Fix exposed ports not being mapped on Docker Desktop - #1602

Draft
cristianrgreco wants to merge 3 commits into
mainfrom
claude/interesting-brattain-7c4d71
Draft

cristianrgreco wants to merge 3 commits into
mainfrom
claude/interesting-brattain-7c4d71

Conversation

@cristianrgreco

Copy link
Copy Markdown
Collaborator

Summary

On Docker Desktop a container's first start can fail with Timed out after 30000ms while waiting for container ports to be bound to the host, although the container is running.

withExposedPorts asked for a random host port with HostPort: "0". For that value the engine inside the Desktop VM picks the port, and Desktop then listens on the same number on the host without checking that it is free there. When it is not, Desktop logs bind: address already in use and the port is never mapped: inspect keeps returning "8080/tcp": [].

  • The engine hands out ports one after the other from 55000-65535. macOS hands out its own ephemeral ports from 49152-65535, so the two overlap.
  • What blocks Desktop's bind: a listener on [::]:<port> from the same user (what Node's server.listen(port) creates), or any socket on that port owned by another user. On the machine this was found on, the holders were long-lived sockets of macOS daemons (remoted, apsd, rapportd).
  • Docker Desktop 4.82.0 tries three times within 25 ms and stops. 4.94.0 retries for about 55 s and then gives up, so a port that is blocked for longer is still never mapped.

This PR sends HostPort: "" instead. For an empty host port Desktop picks the host port itself, so there is no number that can clash. Docker described the difference in docker/for-mac#5588: "0" skips that code "by accident".

Other implementations: Java sends "" (ContainerDef.java), and .NET sends "" and rewrites "0" to it, citing the same Docker comment (ContainerBuilder.cs). Go sends "0".

"0" is kept in two cases:

  • Inside a container. With "" the port in inspect is the host's, and the VM listens on a different one. resolveHost connects through the bridge gateway when /.dockerenv exists, and that gets ECONNREFUSED for the host's port. This repository's dev container is such a setup. isInContainer moves to its own file so both places use it.
  • UDP ports. With "" Docker Desktop 4.94.0 dropped the first datagram sent to a freshly mapped port in 5 of 300 containers started 50 at a time, against 0 of 300 with "0". Docker Desktop before 4.67.0 also reported such a port as 0 (fixed in its release notes for 4.67.0).

This is a different defect from the stale port after a restart in #1599. Neither change fixes the other, and they combine.

Verification

On macOS arm64 with Docker Desktop 4.82.0 (engine 29.6.1) and 4.94.0 (engine 29.8.2).

  • npm run format, npm run lint, npm run knip, npm run check-compiles: clean.
  • New test "should bind to a random host port when the host uses the ports the container runtime picks next". A pilot container started with { container: 8080, host: 0 } shows where the engine's counter is, the test listens on the next three port numbers, then starts a container with withExposedPorts(8080) and checks that it answers.
    • Red with HostPort: "0", on both Desktop versions: Error: Timed out after 30000ms while waiting for container ports to be bound to the host, from inspectContainerUntilPortsExposed via GenericContainer.startContainer.
    • Green with the change: 3 of 3 runs on each version.
  • generic-container.test.ts: 52 passed, 1 skipped, in 3 of 3 runs.
  • resolve-host, generic-container-reuse, generic-container-restart, port-forwarder, reaper and host-port-wait-strategy tests: 40 passed (7 files).

Limits:

  • The new test is only red on Docker Desktop. A Linux engine binds the host port itself and moves on to the next number, so the old code passes there too.
  • Its pilot container uses "0", so on Docker Desktop the test can hit the defect itself when the pilot's number is blocked on the host. That was about 0.3% of port numbers on the machine used.
  • The "0" branch for a process inside a container has no test.
  • Not run locally on a Linux host or with Podman; CI covers both.
  • Not checked on Docker Desktop for Windows or Linux.

Not breaking

  • The public API is unchanged. Only the create request differs, and only for TCP ports without a host binding, outside a container.
  • The engine treats "" and "0" alike: both mean "allocate a port".
  • One visible effect: withReuse() hashes the create options, so a reusable container created by an earlier version is not matched once and a new one is created.

Request random host ports with an empty HostPort instead of "0". For "0"
Docker Desktop opens the port number the engine picked inside its VM on the
host as well, without checking that it is free there, and leaves the port
unmapped when it is not. For an empty HostPort it picks the host port itself.

Keep "0" when running inside a container, where the host is reached through
the bridge gateway and the port must be the one the engine bound.
With an empty HostPort Docker Desktop drops the first datagram sent to a
freshly mapped UDP port now and then, and Docker Desktop before 4.67.0
reported such a port as 0.
@cristianrgreco cristianrgreco added bug Something isn't working patch Backward compatible bug fix labels Oct 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working patch Backward compatible bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant