Skip to content

Stop deriving the ingress proxy port from a fixed upstream port - #6069

Merged
ChrisJBurns merged 2 commits into
mainfrom
fix-ingress-port-collision
Jul 28, 2026
Merged

Stop deriving the ingress proxy port from a fixed upstream port#6069
ChrisJBurns merged 2 commits into
mainfrom
fix-ingress-port-collision

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The group_rm_test.go / rm_group_test.go e2e specs that create several
    workloads of the same MCP server image back-to-back have been failing on
    almost every PR: workloads observed stuck in starting or flipped to a bare
    error status, timing out after 2 minutes even though the underlying
    container started and logged readiness within seconds. Related to Flaky: group rm "should delete group with workloads" times out waiting for readiness #6040
    (closed against an earlier, incomplete diagnosis) and the diagnostics added
    in Deflake CI: TUF warn mode, readiness diagnostics, test races #6063, whose new proxy-log dump captured the actual Docker error:
    Bind for 127.0.0.1:8081 failed: port is already allocated.
  • Root cause: setupIngressContainer (Squid backend) and envoyProxy.SetupIngress
    (Envoy backend) both picked the ingress reverse-proxy's host port as
    UpstreamPort + 1, where UpstreamPort is the container's fixed internal
    MCP listen port (e.g. 8080 for the fetch/gofetch test image) — identical
    across every workload instance of that image. Every concurrently-starting
    workload of the same image therefore requested the same preferred port.
    networking.FindOrUsePort's availability check is a bind-then-close probe
    with no reservation, so it can't see a sibling process's in-flight (not yet
    bound) intent to use that port — all instances see it as free, and whichever
    one's Docker networking setup runs last loses with "port is already allocated".
  • Fix: pick the ingress port at random (FindOrUsePort(0)) instead of deriving
    it from the container's fixed internal port, matching how the per-workload
    host proxy port is already chosen elsewhere in the codebase. This doesn't
    eliminate the underlying TOCTOU in pkg/networking/port.go in principle, but
    removes the deterministic same-image collision, making an actual collision
    astronomically unlikely for a handful of concurrently-starting workloads.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Added TestEnvoyProxy_SetupIngress_ConcurrentSameUpstreamPort, which runs 4
goroutines through SetupIngress sharing the same UpstreamPort and asserts
every returned ingress port is distinct and none equals upstreamPort+1. Fails
against the old code, passes with the fix. Full task build / task test
green.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
pkg/container/docker/client.go setupIngressContainer picks the Squid ingress port at random instead of upstreamPort + 1
pkg/container/docker/envoy.go envoyProxy.SetupIngress picks the ingress port at random instead of spec.UpstreamPort + 1
pkg/container/docker/envoy_test.go New regression test proving concurrent same-image workloads no longer collide on the ingress port

Does this introduce a user-facing change?

No. The ingress proxy port is an internal container-to-container detail (clients connect to the workload's regular host proxy port, unaffected by this change).

Special notes for reviewers

This is not a fixup of #6063 — it doesn't touch any file #6063 changes, and #6063 is diagnostics-only by design (its own description states the readiness-stall root cause was still unknown). This PR is the root-cause fix that #6063's new proxy-log diagnostic made possible to find. The two can merge in either order; no dependency between them.

Generated with Claude Code

Every workload of the same MCP server image shares the same fixed
UpstreamPort (the container's internal listen port), so deriving the
Squid/Envoy ingress proxy's host port as UpstreamPort+1 made every
concurrently-starting workload of that image request the identical
port. Whichever container's Docker networking setup lost the race
failed outright with "port is already allocated", surfacing as e2e
workloads stuck in "starting" or flipped to "error" when several
workloads of the same image start back-to-back.

Pick the ingress port at random instead, matching how the per-workload
host proxy port is already chosen elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, I feel guilty having done the envoy tests!!!

@github-actions github-actions Bot added the size/XS Extra small PR: < 100 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.19%. Comparing base (a099fa0) to head (c9745f8).

Files with missing lines Patch % Lines
pkg/container/docker/client.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6069      +/-   ##
==========================================
- Coverage   72.20%   72.19%   -0.01%     
==========================================
  Files         721      721              
  Lines       75062    75062              
==========================================
- Hits        54198    54192       -6     
- Misses      17003    17012       +9     
+ Partials     3861     3858       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/XS Extra small PR: < 100 lines changed and removed size/XS Extra small PR: < 100 lines changed labels Jul 27, 2026
@ChrisJBurns

Copy link
Copy Markdown
Collaborator

/retest

@ChrisJBurns

Copy link
Copy Markdown
Collaborator

Merging because it fixes most of the issues, there's a small number remaining that I can fix in a separate PR.

@ChrisJBurns
ChrisJBurns merged commit c1146a8 into main Jul 28, 2026
57 of 59 checks passed
@ChrisJBurns
ChrisJBurns deleted the fix-ingress-port-collision branch July 28, 2026 00:12
ChrisJBurns added a commit that referenced this pull request Jul 28, 2026
After the ingress port collision (#6069) is fixed, concurrent isolated
startup still leaves a workload stuck in status "starting" behind a
persistent ingress 502, even though the MCP server is healthy and
listening. The ingress Squid cache_peer targets the MCP container by
name; under concurrent startup the container's Docker DNS record can lag,
Squid caches the failed lookup, and the peer never revives within the
2-minute readiness window. Ordering ingress creation after the MCP
container (the existing mitigation) is not enough when DNS registration
itself races.

Resolve the MCP container's IP on the internal network and give it to the
ingress proxy as the upstream address, so the cache_peer has no DNS
lookup to latch on. A bad IP just yields connection-refused, which Squid
retries and recovers from within seconds. The Envoy backend's ingress
cluster is pointed at the same IP for parity. UpstreamHost falls back to
the workload name when unset, so non-isolated paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ChrisJBurns added a commit that referenced this pull request Jul 28, 2026
…atch) (#6071)

* Make shared network creation idempotent under races

Concurrently starting workloads share the "toolhive-external" Docker
network, and createNetwork checks for existence and then creates in two
non-atomic steps. When several `thv run` supervisors start at once (e.g.
multiple workloads in a group), more than one passes the existence check
and races on NetworkCreate. Docker rejects the duplicate name with a
conflict error, which propagated up as "failed to create external
networks" and left every losing workload in status "error".

Treat a conflict from NetworkCreate as success: a lost race means the
network already exists, which is exactly the desired state. The new
real-daemon test fires eight concurrent createNetwork calls for the same
name and asserts every caller succeeds with a single network created; it
fails against the old code and passes with the fix (skips without Docker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Point ingress proxy at MCP container IP, not name

After the ingress port collision (#6069) is fixed, concurrent isolated
startup still leaves a workload stuck in status "starting" behind a
persistent ingress 502, even though the MCP server is healthy and
listening. The ingress Squid cache_peer targets the MCP container by
name; under concurrent startup the container's Docker DNS record can lag,
Squid caches the failed lookup, and the peer never revives within the
2-minute readiness window. Ordering ingress creation after the MCP
container (the existing mitigation) is not enough when DNS registration
itself races.

Resolve the MCP container's IP on the internal network and give it to the
ingress proxy as the upstream address, so the cache_peer has no DNS
lookup to latch on. A bad IP just yields connection-refused, which Squid
retries and recovers from within seconds. The Envoy backend's ingress
cluster is pointed at the same IP for parity. UpstreamHost falls back to
the workload name when unset, so non-isolated paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants