From 84a2dd476b0227dc6d100d06aad5eee800713895 Mon Sep 17 00:00:00 2001 From: Tempris Admin Date: Sat, 6 Jun 2026 15:28:35 +0700 Subject: [PATCH 1/2] Fix background goroutine leak in ssh.DialContext --- pkg/ssh/ssh_dialer.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/pkg/ssh/ssh_dialer.go b/pkg/ssh/ssh_dialer.go index 31ad18aeb7..7cf983580c 100644 --- a/pkg/ssh/ssh_dialer.go +++ b/pkg/ssh/ssh_dialer.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/docker/cli/cli/connhelper" @@ -126,18 +127,36 @@ func (n contextDialerFn) DialContext(ctx context.Context, network, address strin return n(ctx, network, address) } +// connWithDone wraps a net.Conn and signals a channel when Close() is called, +// allowing the context-cancellation goroutine to exit cleanly. +type connWithDone struct { + net.Conn + done chan struct{} + once sync.Once +} + +func (c *connWithDone) Close() error { + c.once.Do(func() { close(c.done) }) + return c.Conn.Close() +} + func (d *dialer) DialContext(ctx context.Context, n, a string) (net.Conn, error) { conn, err := d.Dial(d.network, d.addr) if err != nil { return nil, err } + wrapped := &connWithDone{Conn: conn, done: make(chan struct{})} go func() { if ctx != nil { - <-ctx.Done() - conn.Close() + select { + case <-ctx.Done(): + conn.Close() + case <-wrapped.done: + // Connection was closed by the caller; exit cleanly. + } } }() - return conn, nil + return wrapped, nil } func (d *dialer) Dial(n, a string) (net.Conn, error) { From 1e0117acce9ff3092fe5e7f82804faebc8cbe03d Mon Sep 17 00:00:00 2001 From: Tempris Admin Date: Sat, 6 Jun 2026 22:43:46 +0700 Subject: [PATCH 2/2] Fix goroutine leak in SSH DialContext: delegate to sshClient.DialContext The monitoring goroutine in DialContext violated net.Dialer.DialContext semantics: context should only govern connection establishment, not connection lifetime. The goroutine also leaked when connections were closed manually while the context remained open. Remove the goroutine and connWithDone wrapper entirely. Delegate to ssh.Client.DialContext which already handles context-cancellable dialing correctly per the Go contract. --- pkg/ssh/ssh_dialer.go | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/pkg/ssh/ssh_dialer.go b/pkg/ssh/ssh_dialer.go index 7cf983580c..4300267d98 100644 --- a/pkg/ssh/ssh_dialer.go +++ b/pkg/ssh/ssh_dialer.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "strings" - "sync" "time" "github.com/docker/cli/cli/connhelper" @@ -127,36 +126,8 @@ func (n contextDialerFn) DialContext(ctx context.Context, network, address strin return n(ctx, network, address) } -// connWithDone wraps a net.Conn and signals a channel when Close() is called, -// allowing the context-cancellation goroutine to exit cleanly. -type connWithDone struct { - net.Conn - done chan struct{} - once sync.Once -} - -func (c *connWithDone) Close() error { - c.once.Do(func() { close(c.done) }) - return c.Conn.Close() -} - func (d *dialer) DialContext(ctx context.Context, n, a string) (net.Conn, error) { - conn, err := d.Dial(d.network, d.addr) - if err != nil { - return nil, err - } - wrapped := &connWithDone{Conn: conn, done: make(chan struct{})} - go func() { - if ctx != nil { - select { - case <-ctx.Done(): - conn.Close() - case <-wrapped.done: - // Connection was closed by the caller; exit cleanly. - } - } - }() - return wrapped, nil + return d.sshClient.DialContext(ctx, d.network, d.addr) } func (d *dialer) Dial(n, a string) (net.Conn, error) {