Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pkg/cmdcheck/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,19 @@ func (r *RealCmdRunner) LookPath(file string) (string, error) {
return exec.LookPath(file)
}

// waitDelay bounds how long Wait blocks after the context is cancelled. Because
// Stdout and Stderr are buffers rather than files, os/exec pipes them and waits
// for EOF, but CommandContext kills only the direct child — a grandchild holding
// the write end keeps the pipe open, so without this the timeout is
// unenforceable and a version command that daemonizes hangs the check forever.
const waitDelay = time.Second

func (r *RealCmdRunner) RunCommandContext(ctx context.Context, name string, args ...string) (stdout, stderr string, err error) {
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // intentional: executing user-specified version check commands
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
cmd.WaitDelay = waitDelay
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
50 changes: 50 additions & 0 deletions pkg/cmdcheck/runner_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build unix

package cmdcheck

import (
"context"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"
)

// A command that exits while a background child still holds the stdout pipe.
// os/exec waits for the pipe to reach EOF, and CommandContext kills only the
// direct child, so without WaitDelay the context deadline is unenforceable and
// RunCommandContext blocks for as long as the grandchild lives.
func TestRunCommandContext_ReturnsWhenGrandchildHoldsThePipe(t *testing.T) {
script := filepath.Join(t.TempDir(), "daemonize.sh")
require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\nsleep 60 &\necho v1.0.0\n"), 0o700)) //nolint:gosec // the script must be executable to run

ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()

done := make(chan struct{})
go func() {
defer close(done)
r := &RealCmdRunner{}
_, _, _ = r.RunCommandContext(ctx, script)
}()

select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("RunCommandContext did not return; the 200ms timeout was not enforced")
}
}

// The ordinary path must be unaffected: a command that finishes normally still
// returns its output rather than being cut off by WaitDelay.
func TestRunCommandContext_NormalCommandStillReturnsOutput(t *testing.T) {
script := filepath.Join(t.TempDir(), "version.sh")
require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\necho v1.2.3\n"), 0o700)) //nolint:gosec // the script must be executable to run

r := &RealCmdRunner{}
stdout, _, err := r.RunCommandContext(context.Background(), script)
require.NoError(t, err)
require.Contains(t, stdout, "v1.2.3")
}