Skip to content

Commit 3966213

Browse files
Cleanup scripts for windows in ShellExec (#2902)
## Why The `cmd.exe` command executor we use to execute scripts in windows writes the script first to disk before executing it. This is necessary because `cmd.exe` does not support inlining the script contents as an argument. Since ShellExecv needs to exit the current CLI process within the confines of the `execv` method to mirror the behaviour of the UNIX version of `execv`, this PR adds a callback that cleans up the temporary script before exiting the `execv` function call. ## Tests Unit tests which verify the script is indeed being cleaned up.
1 parent cef4f52 commit 3966213

File tree

5 files changed

+110
-18
lines changed

5 files changed

+110
-18
lines changed

internal/testutil/copy.go

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,22 @@ func CopyDirectory(t TestingT, src, dst string) {
2424
return os.MkdirAll(filepath.Join(dst, rel), 0o755)
2525
}
2626

27-
// Copy the file to the temporary directory
28-
in, err := os.Open(path)
29-
if err != nil {
30-
return err
31-
}
32-
33-
defer in.Close()
27+
CopyFile(t, path, filepath.Join(dst, rel))
28+
return nil
29+
})
3430

35-
out, err := os.Create(filepath.Join(dst, rel))
36-
if err != nil {
37-
return err
38-
}
31+
require.NoError(t, err)
32+
}
3933

40-
defer out.Close()
34+
func CopyFile(t TestingT, src, dst string) {
35+
srcF, err := os.Open(src)
36+
require.NoError(t, err)
37+
defer srcF.Close()
4138

42-
_, err = io.Copy(out, in)
43-
return err
44-
})
39+
dstF, err := os.Create(dst)
40+
require.NoError(t, err)
41+
defer dstF.Close()
4542

43+
_, err = io.Copy(dstF, srcF)
4644
require.NoError(t, err)
4745
}

libs/exec/execv.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package exec
22

3+
import "os"
4+
35
type ExecvOptions struct {
46
// Args is the name of the command to run and its arguments.
57
// Eg: ["echo", "hello"] for "echo hello"
@@ -10,8 +12,21 @@ type ExecvOptions struct {
1012

1113
// Dir is the working directory of the child process.
1214
Dir string
15+
16+
// It is not possible to execute a cmd.exe script inlined as a argument
17+
// to cmd.exe. They have to be serialized to a file and executed.
18+
// Thus if [Execv] is used to execution a script though cmd.exe,
19+
// the caller should register a cleanup function to clean up the temporary file.
20+
cleanup func()
21+
22+
// Callback to exit the current process in windows. Having this as a function here
23+
// helps with testing.
24+
windowsExit func(status int)
1325
}
1426

1527
func Execv(opts ExecvOptions) error {
28+
if opts.windowsExit == nil {
29+
opts.windowsExit = os.Exit
30+
}
1631
return execv(opts)
1732
}

libs/exec/execv_windows.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,24 @@ import (
1414
// and return the exit code.
1515
// ref: https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/execv-wexecv?view=msvc-170
1616
func execv(opts ExecvOptions) error {
17+
if opts.cleanup != nil {
18+
defer opts.cleanup()
19+
}
20+
21+
windowsExit := func(status int) {
22+
// First clean up the temporary script if it exists.
23+
if opts.cleanup != nil {
24+
opts.cleanup()
25+
}
26+
27+
// Then exit the process.
28+
opts.windowsExit(status)
29+
}
30+
1731
path, err := exec.LookPath(opts.Args[0])
1832
if err != nil {
1933
return fmt.Errorf("looking up %q failed: %w", opts.Args[0], err)
2034
}
21-
2235
cmd := exec.Command(path, opts.Args[1:]...)
2336

2437
cmd.Stdin = os.Stdin
@@ -35,7 +48,8 @@ func execv(opts ExecvOptions) error {
3548

3649
err = cmd.Wait()
3750
if exitErr, ok := err.(*exec.ExitError); ok {
38-
os.Exit(exitErr.ExitCode())
51+
windowsExit(exitErr.ExitCode())
52+
return nil
3953
}
4054
if err != nil {
4155
return fmt.Errorf("running %s failed: %w", strings.Join(opts.Args, " "), err)
@@ -44,6 +58,6 @@ func execv(opts ExecvOptions) error {
4458
// Unix implementation of execv never returns control to the CLI process.
4559
// To emulate this behavior, we exit early here if the child process exits
4660
// successfully.
47-
os.Exit(0)
61+
windowsExit(0)
4862
return nil
4963
}

libs/exec/shell_execv.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ func shellExecvOpts(content, dir string, env []string) (ExecvOptions, error) {
1818
Args: args,
1919
Env: env,
2020
Dir: dir,
21+
cleanup: func() {
22+
ec.cleanup()
23+
},
2124
}, nil
2225
}
2326

libs/exec/shell_execv_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package exec
22

33
import (
4+
"os"
45
"os/exec"
6+
"path/filepath"
7+
"runtime"
58
"testing"
69

10+
"github.com/databricks/cli/internal/testutil"
711
"github.com/stretchr/testify/assert"
812
"github.com/stretchr/testify/require"
913
)
@@ -21,3 +25,61 @@ func TestShellExecvOpts(t *testing.T) {
2125
assert.Equal(t, "-ec", opts.Args[1])
2226
assert.Equal(t, "echo hello", opts.Args[2])
2327
}
28+
29+
func TestShellExecv_Windows(t *testing.T) {
30+
if runtime.GOOS != "windows" {
31+
t.Skip("skipping windows test")
32+
}
33+
34+
cmdExePath, err := exec.LookPath("cmd.exe")
35+
require.NoError(t, err)
36+
37+
// Cleanup environment so that other shells like bash and sh are not used.
38+
testutil.NullEnvironment(t)
39+
40+
// Configure PATH so that only cmd.exe shows up.
41+
binDir := t.TempDir()
42+
testutil.CopyFile(t, cmdExePath, filepath.Join(binDir, "cmd.exe"))
43+
os.Setenv("PATH", binDir)
44+
45+
tests := []struct {
46+
name string
47+
content string
48+
exitCode int
49+
}{
50+
{name: "success", content: "echo hello", exitCode: 0},
51+
{name: "non-zero exit", content: "exit 127", exitCode: 127},
52+
{name: "command error", content: "not-a-command", exitCode: 1},
53+
}
54+
55+
for _, test := range tests {
56+
dir := t.TempDir()
57+
t.Setenv("TMP", dir)
58+
59+
opts, err := shellExecvOpts(test.content, dir, []string{})
60+
require.NoError(t, err)
61+
62+
// Verify that the temporary file is created.
63+
files, err := os.ReadDir(dir)
64+
require.NoError(t, err)
65+
assert.Len(t, files, 1)
66+
assert.Regexp(t, "cli-exec.*\\.cmd", files[0].Name())
67+
68+
exitCode := -1
69+
opts.windowsExit = func(status int) {
70+
exitCode = status
71+
}
72+
73+
// Execute the script.
74+
err = Execv(opts)
75+
require.NoError(t, err)
76+
77+
// Verify that the temporary file is cleaned up after execution.
78+
files, err = os.ReadDir(dir)
79+
require.NoError(t, err)
80+
assert.Len(t, files, 0)
81+
82+
// Verify that CLI would exit with the correct exit code.
83+
assert.Equal(t, test.exitCode, exitCode)
84+
}
85+
}

0 commit comments

Comments
 (0)