Fix goroutine leak in the proxy by spicysharkco · Pull Request #14811 · k3s-io/k3s · GitHub
Skip to content

Fix goroutine leak in the proxy - #14811

Merged
brandond merged 1 commit into
k3s-io:mainfrom
spicysharkco:fix-proxy-goroutine-leak
Oct 15, 2026
Merged

brandond merged 1 commit into
k3s-io:mainfrom
spicysharkco:fix-proxy-goroutine-leak

Conversation

@spicysharkco

@spicysharkco spicysharkco commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Proposed Changes

Proxy starts one pipe goroutine per direction and returns the first error it receives on errc. errc was unbuffered, and the done flag that kept a second sender away was a plain bool written by both goroutines. When both directions failed at about the same time (the usual case on a TCP reset), both goroutines could pass the done check; the second one then blocked forever on the send, holding its 32 KiB buffer and both connections.

errc now has room for both errors, so each pipe goroutine sends once and returns. The done flag and the err method are gone, which also removes the data race. Proxy still returns the first error and logs it unless it is io.EOF. It now logs once per connection; with the race, both goroutines could log. The second goroutine exits as soon as its Read or Write fails, which Proxy triggers by closing both connections on return.

I kept "return on the first error" rather than the wait-for-both-directions pattern in remotedialer's client_dialer.go, so Proxy cannot hang on a connection whose Close does not unblock a pending Read. Following @bheemareddy's review suggestion, which matches the classification in #14810, connection resets and broken pipes (syscall.ECONNRESET, syscall.EPIPE, matched with errors.As) are now logged at debug level instead of as Proxy error: warnings. Other errors are still logged as warnings, and io.EOF is still not logged.

Types of Changes

Bugfix

Verification

  1. Run a server with the default egress selector mode (agent) and an aggregated APIService backed by an in-cluster Service, as in Goroutine leak in pkg/daemons/control/proxy: second pipe goroutine blocks forever on unbuffered errc when both directions fail (data race on done) #14787.
  2. After some tunnel connections have ended with Proxy error: ... connection reset by peer, dump goroutines through the kubelet: kubectl get --raw "/api/v1/nodes/$N/proxy/debug/pprof/goroutine?debug=2".
  3. No goroutine is left blocked in pkg/daemons/control/proxy. Before this change, the count of proxy.(*proxy).err goroutines only grows.

Testing

Added Test_UnitProxyBothDirectionsFail in pkg/daemons/control/proxy. It runs Proxy 10000 times on connections whose reads and writes always fail, then waits for the goroutine count to return to where it started. On the old code it fails (1029 goroutines left over) and -race reports the race on done; with this change it passes 20 of 20 runs of go test -race -count=20 ./pkg/daemons/control/proxy/. It catches the bug on multi-core runners; with GOMAXPROCS=1 the old code can pass. A blocked goroutine is not visible to an e2e test, so this is a unit test; I can drop it if you prefer.

Added Test_UnitProxyLogLevel, which checks the level for io.EOF, a TCP reset, a broken pipe and an unrelated error.

Linked Issues

Fixes #14787

User-Facing Change

Fixed a goroutine leak in the egress selector tunnel proxy when both directions of a proxied connection fail. Connection resets and broken pipes on proxied connections are now logged at debug level.

@brandond

Copy link
Copy Markdown
Member

Comment thread pkg/daemons/control/proxy/proxy.go
@brandond

brandond commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

I think I prefer this one of the two; the overall change is less involved.

I do like the suggestion of filtering out syscall.ECONNRESET and syscall.EPIPE.

I would suggest dropping the wrap of errors with errors.WithMessage, and then just doing

func isConnTeardown(err error) bool {
  if se, ok := err.(syscall.Errno); ok {
    return se == syscall.ECONNRESET || se == syscall.EPIPE
  }
  return false
}

@spicysharkco
spicysharkco force-pushed the fix-proxy-goroutine-leak branch from 19aafd3 to 990f2e5 Compare October 15, 2026 19:01
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 52.06%. Comparing base (d4d3dcd) to head (46cc90b).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
pkg/daemons/control/proxy/proxy.go 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #14811      +/-   ##
==========================================
- Coverage   52.13%   52.06%   -0.07%     
==========================================
  Files         197      197              
  Lines       16012    16008       -4     
==========================================
- Hits         8348     8335      -13     
- Misses       6356     6362       +6     
- Partials     1308     1311       +3     
Flag Coverage Δ
e2etests 41.70% <81.81%> (-0.03%) ⬇️
inttests 39.94% <81.81%> (+0.02%) ⬆️
unittests 27.34% <90.90%> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

@spicysharkco

Copy link
Copy Markdown
Contributor Author

Thanks. I'll drop the errors.WithMessage wrap and use your isConnTeardown, with one change: errors.As instead of the type assertion.

Errors from a TCP conn don't arrive as a bare syscall.Errno. They come as *net.OpError wrapping *os.SyscallError wrapping the errno, as in the log line in #14787 (write tcp ...: write: connection reset by peer). The type assertion only sees the outer *net.OpError, so it never matches. On a real loopback reset:

err:  read tcp 127.0.0.1:53277->127.0.0.1:53276: read: connection reset by peer
type: *net.OpError
err.(syscall.Errno) ok: false
errors.Is(err, ECONNRESET): true

Test_UnitProxyLogLevel builds its errors in that wrapped shape, so the reset and broken-pipe cases catch it.

func isConnTeardown(err error) bool {
	var se syscall.Errno
	if errors.As(err, &se) {
		return se == syscall.ECONNRESET || se == syscall.EPIPE
	}
	return false
}

I also dropped the net.ErrClosed case to keep it to the two errnos you named.

Signed-off-by: Leo King <spicysharkco@gmail.com>
Signed-off-by: Brad Davidson <brad.davidson@rancher.com>
@brandond
brandond force-pushed the fix-proxy-goroutine-leak branch from 78623d5 to 46cc90b Compare October 15, 2026 19:24
@brandond

Copy link
Copy Markdown
Member

@brandond
brandond merged commit b3b26bc into k3s-io:main Oct 15, 2026
102 of 103 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.

Goroutine leak in pkg/daemons/control/proxy: second pipe goroutine blocks forever on unbuffered errc when both directions fail (data race on done)

4 participants