Skip to content

Fix stale mapped ports after restarting a container on Docker Desktop - #1599

Draft
cristianrgreco wants to merge 2 commits into
mainfrom
claude/quizzical-kirch-6f1a17
Draft

cristianrgreco wants to merge 2 commits into
mainfrom
claude/quizzical-kirch-6f1a17

Conversation

@cristianrgreco

@cristianrgreco cristianrgreco commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

On Docker Desktop, restart() and starting a stopped reusable container can return a mapped port that stops working moments later. Tests then fail with ECONNRESET, ECONNREFUSED or other side closed straight after the restart.

Cause

Docker Desktop's API proxy rewrites NetworkSettings.Ports using its own list of port forwards, looked up by container and container port. Desktop updates that list after the stop, start or restart call has already returned. Until it catches up, inspect reports the forward from the previous run. The engine's own socket reports the right port throughout.

Seen on Docker Desktop 4.82.0 (engine 29.6.1, macOS arm64), with the same container inspected through both sockets right after restart returned (output abridged):

18:06:59.982 restart returned
18:06:59.986 inspect proxy=56225 engine=56276 | old port 56225: open
18:07:01.786     com.docker.backend: exposer.Remove(17e0c6b2e1d0)
18:07:02.916     com.docker.backend: exposer.Add(17e0c6b2e1d0, [TCP 0.0.0.0:56276 -> 172.17.0.15:8080])
18:07:02.998 inspect proxy=56276 engine=56276 | old port 56225: ECONNREFUSED

The old forward still points at the container's IP, which usually survives a restart, so the wait strategy passes on the old port before Desktop closes it. The window is about 100 ms on an idle Desktop and seconds when other containers are starting and being removed.

Fix

Nothing in the Docker API marks the stale answer. What a client can observe is that the old host port refuses connections from the moment Desktop drops the old forward. This PR waits on that:

  • restart() stops the container, waits until every TCP host port it had mapped refuses connections, then starts it. It no longer calls Docker's restart endpoint.
  • stop({ remove: false }) and withAutoRemove(false) wait the same way before stop() resolves. That covers a stop followed by a reuse start in the same process.

On runtimes that release the port when the stop call returns, the wait is one refused connection per port.

Decision for the reviewer

Two shapes are possible:

  • This PR: every runtime takes the stop, wait, start path.
  • Alternative: keep Docker's restart endpoint and only do this when the runtime reports Docker Desktop.

I chose the first because CI runs on Linux, so a Desktop-only branch would never run there.

Relation to sending an empty HostPort

A separate change is being considered for a different Docker Desktop failure, where a port is never published at all. It sends HostPort: "" instead of "0", so Desktop picks the host port itself. That change does not fix this problem, and this PR works with either value:

On Docker Desktop 4.82.0 Stale after restart() Stale after stop then reuse start
HostPort: "", without this PR 1 of 48 8 of 48
HostPort: "", with this PR 0 of 48 0 of 48

Through the Docker API alone, with HostPort: "", the first inspect after restart returned still had the old host port in 7 of 24 restarts.

Limits

  • A container that publishes only UDP ports is not covered. A UDP port cannot be probed.
  • A reusable container that another process stopped moments earlier is not covered. Its old port is unknown.
  • Docker no longer emits a restart event for restart().
  • A container created with HostConfig.AutoRemove is removed by the stop and cannot be started again. The library only sets that for Ryuk.
  • It relies on Desktop closing the listener when it drops the forward. That is observed, not documented.
  • If restart() is called before Desktop has opened the forward for the current run, the wait passes at once. The default wait strategy rules this out because it connects to the host port. A log-only wait strategy does not. Not seen in 32 attempts.
  • If another process binds the released port, the wait fails after 30 seconds.

Other implementations

None of them handles this, and I found no Docker report for it.

Verification

All on Docker Desktop 4.82.0, macOS arm64. Not run on Linux.

  • npm run format, npm run lint, npm run knip, npm run check-compiles: clean.
  • npx vitest run on wait-until-host-ports-released.test.ts, generic-container-restart.test.ts, generic-container-reuse.test.ts and generic-container.test.ts: 71 passed, 1 skipped.
  • npx vitest run packages/modules/redis/src packages/modules/valkey/src: Valkey passed. Redis failed once in should start redis-stack with custom env and keeping default args with Timed out after 30000ms while waiting for container ports to be bound to the host, on a first start, and passed 12 of 12 on rerun. That is a different Docker Desktop problem (its log shows bind: address already in use for that container's forward), in code this PR does not touch.

Red-green

The stale port needs Docker Desktop to be busy, so no test in the suite is red every time without the change.

Without the change With the change
Mapped port stale after restart() 10 of 72 0 of 72
Mapped port stale after stop({ remove: false }) then a reuse start() 38 of 72 0 of 72
should release the mapped port before stop resolves when the container is not removed, whole file failed 1 of 3 runs (expected true to be false) passed 3 of 3
Same test, run alone passed 8 of 8 passed 8 of 8

The first two rows come from a temporary probe that is not part of this PR: 6 containers, 4 rounds, 3 runs each way, while 4 workers created, started and force-removed other containers. It compared getMappedPort(8080) with the engine's own socket.

The unit tests in wait-until-host-ports-released.test.ts are new with the helper. Breaking the wait condition fails 3 of them, and breaking the UDP filter fails 1.

Not breaking

No public API changes. restart() still resolves once the container is ready again, and stop() still resolves with the stopped container. ContainerRuntimeClient's container.restart is unchanged.

Docker Desktop's API proxy reports a container's published ports from its
own list of port forwards, which it updates after the stop, start or
restart call has returned. Until then inspect returns the host port from
the previous run, so restart() and a reuse start of a stopped container
could hand out a port that stops working moments later.

restart() now stops the container, waits until the TCP host ports it had
mapped refuse connections, then starts it. stop() without removal waits
the same way before resolving.

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