Skip to content

reader/contextutil: ConnReadN leaks a goroutine per context-cancelled read (unbuffered chan in ExecFuncWithTwoReturns) #759

Description

@G360-Niek

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

go run .
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)

  1. Buffer the result channelch := 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions