Description
contextutil.ExecFuncWithTwoReturns spawns its worker goroutine with an unbuffered result channel and abandons it when the caller returns via ctx.Done(). The worker then blocks forever on the send (no receiver). reader.ConnReadN is built on top of ExecFuncWithTwoReturns, so every ConnReadN call whose context is cancelled/expired before the read completes leaks a goroutine (and pins its connection).
This is not a misuse pattern — it's the normal timeout path. Under a long-lived process doing many short-deadline reads against slow/unresponsive peers, the leaked goroutines (and their connections/FDs) accumulate without bound.
Version
github.com/projectdiscovery/utils@v0.11.1 (latest tag), Go 1.26. Also present on main (the channel is unbuffered there too).
Reproduction
Standalone, deterministic, no external deps: https://gist.github.com/G360-Niek/436ac19c7bd15779e6a6afa20176074a
ExecFuncWithTwoReturns x200 goroutines 2 -> 202 leaked ~200
ConnReadN x200 (no close) goroutines 202 -> 402 leaked ~200
ConnReadN x200 (Close after) goroutines 402 -> 602 leaked ~200
leaked utils workers parked on:
chan send (unbuffered result chan, no receiver): 400
socket read (internal/poll.(*FD).Read): 200
other: 0
Every timed-out operation leaks exactly one goroutine (600 ops → 600 leaked), in one of two states:
[chan send] — the worker finished fn() and is blocked forever on ch <- ... because the caller already returned via ctx.Done() and nothing will receive.
[IO wait] — for ConnReadN, the worker is still blocked in io.CopyN / FD.Read; a context deadline can't interrupt a blocking syscall, so it parks until the connection is closed.
Note the third scenario: closing the connection after the read does not help — it only converts the [IO wait] leak into a [chan send] leak.
Root cause
context/NContext.go:
func ExecFuncWithTwoReturns[T1 any](ctx context.Context, fn func() (T1, error)) (T1, error) {
ch := make(chan twoValueCtx[T1, error]) // <-- unbuffered
go func() {
x, y := fn()
ch <- twoValueCtx[T1, error]{var1: x, var2: y} // blocks forever once caller took ctx.Done()
}()
select {
case <-ctx.Done():
return tmp, ctx.Err()
case v := <-ch:
return v.var1, v.var2
}
}
reader/conn_read.go runs the blocking io.CopyN(pw, io.LimitReader(reader, N), N) as that fn, so a slow/silent peer leaves the worker stuck in the read as well.
Suggested fix (both verified in the repro)
-
Buffer the result channel — ch := make(chan twoValueCtx[T1, error], 1). The worker can always send-and-exit even after the caller left. Re-running the repro with only this change:
ExecFuncWithTwoReturns x200 goroutines 2 -> 2 leaked ~0
ConnReadN x200 (no close) goroutines 2 -> 202 leaked ~200
ConnReadN x200 (Close after) goroutines 202 -> 202 leaked ~0
ExecFuncWithTwoReturns stops leaking, and ConnReadN becomes fully cleanable — a caller that closes the connection on cancellation now leaks nothing.
-
ConnReadN: unblock the read on cancellation — the residual [IO wait] leak (a ConnReadN whose caller doesn't close the connection) needs ConnReadN to close, or set a read deadline on, the underlying reader when ctx is cancelled, so the blocking FD.Read returns instead of parking the worker.
Happy to open a PR for (1) if that's the preferred direction.
Description
contextutil.ExecFuncWithTwoReturnsspawns its worker goroutine with an unbuffered result channel and abandons it when the caller returns viactx.Done(). The worker then blocks forever on the send (no receiver).reader.ConnReadNis built on top ofExecFuncWithTwoReturns, so everyConnReadNcall whose context is cancelled/expired before the read completes leaks a goroutine (and pins its connection).This is not a misuse pattern — it's the normal timeout path. Under a long-lived process doing many short-deadline reads against slow/unresponsive peers, the leaked goroutines (and their connections/FDs) accumulate without bound.
Version
github.com/projectdiscovery/utils@v0.11.1(latest tag), Go 1.26. Also present onmain(the channel is unbuffered there too).Reproduction
Standalone, deterministic, no external deps: https://gist.github.com/G360-Niek/436ac19c7bd15779e6a6afa20176074a
Every timed-out operation leaks exactly one goroutine (600 ops → 600 leaked), in one of two states:
[chan send]— the worker finishedfn()and is blocked forever onch <- ...because the caller already returned viactx.Done()and nothing will receive.[IO wait]— forConnReadN, the worker is still blocked inio.CopyN/FD.Read; a context deadline can't interrupt a blocking syscall, so it parks until the connection is closed.Note the third scenario: closing the connection after the read does not help — it only converts the
[IO wait]leak into a[chan send]leak.Root cause
context/NContext.go:reader/conn_read.goruns the blockingio.CopyN(pw, io.LimitReader(reader, N), N)as thatfn, so a slow/silent peer leaves the worker stuck in the read as well.Suggested fix (both verified in the repro)
Buffer the result channel —
ch := make(chan twoValueCtx[T1, error], 1). The worker can always send-and-exit even after the caller left. Re-running the repro with only this change:ExecFuncWithTwoReturnsstops leaking, andConnReadNbecomes fully cleanable — a caller that closes the connection on cancellation now leaks nothing.ConnReadN: unblock the read on cancellation — the residual[IO wait]leak (aConnReadNwhose caller doesn't close the connection) needsConnReadNto close, or set a read deadline on, the underlying reader whenctxis cancelled, so the blockingFD.Readreturns instead of parking the worker.Happy to open a PR for (1) if that's the preferred direction.