Skip to content

Commit a80dcff

Browse files
committed
reexec: add "CommandContext"
Adds a CommandContext command, to provide the equivalent of exec.CommandContext. Signed-off-by: Sebastiaan van Stijn <[email protected]>
1 parent d05b721 commit a80dcff

File tree

4 files changed

+130
-0
lines changed

4 files changed

+130
-0
lines changed

reexec/reexec.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package reexec
88

99
import (
10+
"context"
1011
"fmt"
1112
"os"
1213
"os/exec"
@@ -52,6 +53,23 @@ func Command(args ...string) *exec.Cmd {
5253
return command(args...)
5354
}
5455

56+
// CommandContext is like [Command] but includes a context. It uses
57+
// [exec.CommandContext] under the hood.
58+
//
59+
// The provided context is used to interrupt the process
60+
// (by calling cmd.Cancel or [os.Process.Kill])
61+
// if the context becomes done before the command completes on its own.
62+
//
63+
// CommandContext sets the command's Cancel function to invoke the Kill method
64+
// on its Process, and leaves its WaitDelay unset. The caller may change the
65+
// cancellation behavior by modifying those fields before starting the command.
66+
func CommandContext(ctx context.Context, args ...string) *exec.Cmd {
67+
if ctx == nil {
68+
panic("nil Context")
69+
}
70+
return commandContext(ctx, args...)
71+
}
72+
5573
// Self returns the path to the current process's binary.
5674
//
5775
// On Linux, it returns "/proc/self/exe", which provides the in-memory version

reexec/reexec_linux.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package reexec
22

33
import (
4+
"context"
45
"os/exec"
56
"syscall"
67
)
@@ -16,3 +17,15 @@ func command(args ...string) *exec.Cmd {
1617
}
1718
return cmd
1819
}
20+
21+
func commandContext(ctx context.Context, args ...string) *exec.Cmd {
22+
// We try to stay close to exec.Command's behavior, but after
23+
// constructing the cmd, we remove "Self()" from cmd.Args, which
24+
// is prepended by exec.Command.
25+
cmd := exec.CommandContext(ctx, Self(), args...)
26+
cmd.Args = cmd.Args[1:]
27+
cmd.SysProcAttr = &syscall.SysProcAttr{
28+
Pdeathsig: syscall.SIGTERM,
29+
}
30+
return cmd
31+
}

reexec/reexec_other.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package reexec
44

55
import (
6+
"context"
67
"os/exec"
78
)
89

@@ -14,3 +15,12 @@ func command(args ...string) *exec.Cmd {
1415
cmd.Args = cmd.Args[1:]
1516
return cmd
1617
}
18+
19+
func commandContext(ctx context.Context, args ...string) *exec.Cmd {
20+
// We try to stay close to exec.Command's behavior, but after
21+
// constructing the cmd, we remove "Self()" from cmd.Args, which
22+
// is prepended by exec.Command.
23+
cmd := exec.CommandContext(ctx, Self(), args...)
24+
cmd.Args = cmd.Args[1:]
25+
return cmd
26+
}

reexec/reexec_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
package reexec
22

33
import (
4+
"context"
5+
"errors"
46
"fmt"
57
"os"
68
"os/exec"
79
"path/filepath"
810
"reflect"
911
"strings"
1012
"testing"
13+
"time"
1114
)
1215

1316
const (
1417
testReExec = "test-reexec"
1518
testReExec2 = "test-reexec2"
19+
testReExec3 = "test-reexec3"
1620
)
1721

1822
func init() {
@@ -27,6 +31,11 @@ func init() {
2731
fmt.Println("Hello", testReExec2, args)
2832
os.Exit(0)
2933
})
34+
Register(testReExec3, func() {
35+
fmt.Println("Hello " + testReExec3)
36+
time.Sleep(1 * time.Second)
37+
os.Exit(0)
38+
})
3039
Init()
3140
}
3241

@@ -112,6 +121,86 @@ func TestCommand(t *testing.T) {
112121
}
113122
}
114123

124+
func TestCommandContext(t *testing.T) {
125+
tests := []struct {
126+
doc string
127+
cmdAndArgs []string
128+
cancel bool
129+
expOut string
130+
expError string
131+
}{
132+
{
133+
doc: "basename",
134+
cmdAndArgs: []string{testReExec2},
135+
expOut: "Hello test-reexec2",
136+
},
137+
{
138+
doc: "full path",
139+
cmdAndArgs: []string{filepath.Join("something", testReExec2)},
140+
expOut: "Hello test-reexec2",
141+
},
142+
{
143+
doc: "command with args",
144+
cmdAndArgs: []string{testReExec2, "--some-flag", "some-value", "arg1", "arg2"},
145+
expOut: `Hello test-reexec2 (args: []string{"--some-flag", "some-value", "arg1", "arg2"})`,
146+
},
147+
{
148+
doc: "context canceled",
149+
cancel: true,
150+
cmdAndArgs: []string{testReExec2},
151+
expError: context.Canceled.Error(),
152+
},
153+
{
154+
doc: "context timeout",
155+
cmdAndArgs: []string{testReExec3},
156+
expOut: "Hello test-reexec3",
157+
expError: "signal: killed",
158+
},
159+
}
160+
161+
for _, tc := range tests {
162+
t.Run(tc.doc, func(t *testing.T) {
163+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
164+
defer cancel()
165+
166+
cmd := CommandContext(ctx, tc.cmdAndArgs...)
167+
if !reflect.DeepEqual(cmd.Args, tc.cmdAndArgs) {
168+
t.Fatalf("got %+v, want %+v", cmd.Args, tc.cmdAndArgs)
169+
}
170+
171+
w, err := cmd.StdinPipe()
172+
if err != nil {
173+
t.Fatalf("Error on pipe creation: %v", err)
174+
}
175+
defer func() { _ = w.Close() }()
176+
if tc.cancel {
177+
cancel()
178+
}
179+
out, err := cmd.CombinedOutput()
180+
if tc.cancel {
181+
if !errors.Is(err, context.Canceled) {
182+
t.Errorf("got %[1]v (%[1]T), want %v", err, context.Canceled)
183+
}
184+
}
185+
if tc.expError != "" {
186+
if err == nil {
187+
t.Errorf("expected error, got nil")
188+
}
189+
if err.Error() != tc.expError {
190+
t.Errorf("got %q, want %q", err.Error(), tc.expError)
191+
}
192+
} else if err != nil {
193+
t.Errorf("error on re-exec cmd: %v, out: %v", err, string(out))
194+
}
195+
196+
actual := strings.TrimSpace(string(out))
197+
if actual != tc.expOut {
198+
t.Errorf("got %v, want %v", actual, tc.expOut)
199+
}
200+
})
201+
}
202+
}
203+
115204
func TestNaiveSelf(t *testing.T) {
116205
if os.Getenv("TEST_CHECK") == "1" {
117206
os.Exit(2)

0 commit comments

Comments
 (0)