From 69c0808674a12ced5a7c41f3a9531305ba2866a5 Mon Sep 17 00:00:00 2001 From: "devsy-app[bot]" <277138668+devsy-app[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:30:07 +0000 Subject: [PATCH 1/2] fix(tunnel): join tunnel goroutine on handler exit awaitPair returned without joining the tunnel goroutine when the handler completed first, leaving the tunnel blocked on its stdin pipe read and orphaning both the goroutine and the pipe write ends. Mirror the tunnel-first branch: cancel the context, close the pipe write ends, then join the tunnel goroutine so its error is surfaced and no goroutine is leaked. Add TestRunPairJoinsTunnelWhenHandlerFinishesFirst as a regression guard. --- pkg/tunnel/pipebridge.go | 8 ++++---- pkg/tunnel/pipebridge_test.go | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/pkg/tunnel/pipebridge.go b/pkg/tunnel/pipebridge.go index ce4e82ec71..2449b0ebec 100644 --- a/pkg/tunnel/pipebridge.go +++ b/pkg/tunnel/pipebridge.go @@ -74,10 +74,10 @@ func awaitPair( select { case handlerErr = <-handlerChan: - select { - case tunnelErr = <-tunnelChan: - default: - } + cancel() + _ = stdoutWriter.Close() + _ = stdinWriter.Close() + tunnelErr = <-tunnelChan case tunnelErr = <-tunnelChan: cancel() _ = stdoutWriter.Close() diff --git a/pkg/tunnel/pipebridge_test.go b/pkg/tunnel/pipebridge_test.go index 1511038d94..d21219a0c2 100644 --- a/pkg/tunnel/pipebridge_test.go +++ b/pkg/tunnel/pipebridge_test.go @@ -214,3 +214,40 @@ func TestRunPairPipesClosedAfterReturn(t *testing.T) { t.Fatal("timed out waiting for write checks") } } + +// TestRunPairJoinsTunnelWhenHandlerFinishesFirst verifies that when the handler +// completes first, awaitPair unblocks and joins the tunnel goroutine instead of +// orphaning it. The tunnel blocks reading from its stdin until the pipe is +// closed; if awaitPair returns without closing the pipe, the tunnel goroutine +// would never exit. +func TestRunPairJoinsTunnelWhenHandlerFinishesFirst(t *testing.T) { + t.Parallel() + + pb, err := NewPipeBridge() + if err != nil { + t.Fatalf("NewPipeBridge() error = %v", err) + } + defer pb.Close() + + tunnelExited := make(chan struct{}) + tunnel := func(_ context.Context, stdin *os.File, _ *os.File) error { + buf := make([]byte, 1) + _, err := stdin.Read(buf) // blocks until stdin write end is closed + close(tunnelExited) + return err + } + + handler := func(_ context.Context, _ *os.File, _ *os.File) error { + return nil + } + + if err := pb.RunPair(context.Background(), tunnel, handler); err != nil { + t.Fatalf("RunPair() error = %v", err) + } + + select { + case <-tunnelExited: + case <-time.After(2 * time.Second): + t.Fatal("tunnel goroutine was orphaned when handler finished first") + } +} From 8ae61e77286c8dcffeec861b3b2adc39a7530703 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 12 Aug 2026 11:28:52 -0500 Subject: [PATCH 2/2] style: update comments --- pkg/tunnel/pipebridge_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/tunnel/pipebridge_test.go b/pkg/tunnel/pipebridge_test.go index d21219a0c2..bf7b122759 100644 --- a/pkg/tunnel/pipebridge_test.go +++ b/pkg/tunnel/pipebridge_test.go @@ -215,11 +215,6 @@ func TestRunPairPipesClosedAfterReturn(t *testing.T) { } } -// TestRunPairJoinsTunnelWhenHandlerFinishesFirst verifies that when the handler -// completes first, awaitPair unblocks and joins the tunnel goroutine instead of -// orphaning it. The tunnel blocks reading from its stdin until the pipe is -// closed; if awaitPair returns without closing the pipe, the tunnel goroutine -// would never exit. func TestRunPairJoinsTunnelWhenHandlerFinishesFirst(t *testing.T) { t.Parallel()