Skip to content

Commit f4a7589

Browse files
authored
internal/nix: improve command error handling (#2125)
Add a new `cmd` type that wraps `exec.Cmd` and provides better error handling when a Nix command fails. Calls to `commandContext`, `exec.Command` and `exec.CommandContext` are consolidated into the `command` function, which now returns a `*cmd` instead of an `*exec.Cmd`. The `cmd.Run` and related methods return an error that provides more detail about the command in the following format: nix: command ("error"|"canceled"|"timed out"): <shell command>: <stderr>: <exec error> The shell command in the error is a properly-escaped shell string that can be copy/pasted to re-run the command. If Nix prints a line with the prefix "error: " to stderr, then the error will include that line in its message. If there are multiple lines with that prefix, it only includes the last one. Otherwise, it will include `exec.ExitError.Stderr` with control characters escaped. When the command exits on its own, the error includes the exit code. If a signal (either from Devbox or the system) terminates the command, the error includes the signal name instead of a -1 exit code. Below are some examples of how errors will look in logs and Sentry. Missing package: > nix: command error: nix --extra-experimental-features ca-derivations --option experimental-features 'nix-command flakes fetch-closure' search 'flake:nixpkgs#torpalorp' ^ --json: flake 'flake:nixpkgs' does not provide attribute 'packages.aarch64-darwin.torpalorp', 'legacyPackages.aarch64-darwin.torpalorp' or 'torpalorp': exit code 1 Bad flake reference: > nix --extra-experimental-features ca-derivations --option experimental-features 'nix-command flakes fetch-closure' path-info github:blah/thisisnotarepo --json --impure: unable to download 'https://api.github.com/repos/blah/thisisnotarepo/commits/HEAD': HTTP error 404: exit code 1 Killed process: > nix: command error: nix --extra-experimental-features ca-derivations --option experimental-features 'nix-command flakes fetch-closure' build --impure --no-link /nix/store/vz0f0ydykljjji91a94sq8jskh3lpl4h-rustc-wrapper-1.77.2 /nix/store/0r6hm57fmlzhngn76v7vh7vzfp7qrsq8-rustc-wrapper-1.77.2-man: exit due to signal 9 (killed)
1 parent b8ef803 commit f4a7589

File tree

11 files changed

+293
-113
lines changed

11 files changed

+293
-113
lines changed

internal/nix/build.go

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ import (
44
"context"
55
"io"
66
"os"
7-
"os/exec"
87
"strings"
98

10-
"github.com/pkg/errors"
119
"go.jetpack.io/devbox/internal/debug"
12-
"go.jetpack.io/devbox/internal/redact"
1310
)
1411

1512
type BuildArgs struct {
@@ -23,9 +20,9 @@ type BuildArgs struct {
2320
func Build(ctx context.Context, args *BuildArgs, installables ...string) error {
2421
defer debug.FunctionTimer().End()
2522
// --impure is required for allowUnfreeEnv/allowInsecureEnv to work.
26-
cmd := commandContext(ctx, "build", "--impure")
27-
cmd.Args = append(cmd.Args, args.Flags...)
28-
cmd.Args = append(cmd.Args, installables...)
23+
cmd := command("build", "--impure")
24+
cmd.Args = appendArgs(cmd.Args, args.Flags)
25+
cmd.Args = appendArgs(cmd.Args, installables)
2926
// Adding extra substituters only here to be conservative, but this could also
3027
// be added to ExperimentalFlags() in the future.
3128
if len(args.ExtraSubstituters) > 0 {
@@ -48,16 +45,5 @@ func Build(ctx context.Context, args *BuildArgs, installables ...string) error {
4845
cmd.Stderr = args.Writer
4946

5047
debug.Log("Running cmd: %s\n", cmd)
51-
if err := cmd.Run(); err != nil {
52-
if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) {
53-
debug.Log("Nix build exit code: %d, output: %s\n", exitErr.ExitCode(), exitErr.Stderr)
54-
return redact.Errorf("nix build exit code: %d, output: %s, err: %w",
55-
redact.Safe(exitErr.ExitCode()),
56-
exitErr.Stderr,
57-
err,
58-
)
59-
}
60-
return err
61-
}
62-
return nil
48+
return cmd.Run(ctx)
6349
}

internal/nix/cache.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ func CopyInstallableToCache(
1818
env []string,
1919
) error {
2020
fmt.Fprintf(out, "Copying %s to %s\n", installable, to)
21-
cmd := commandContext(
22-
ctx,
21+
cmd := command(
2322
"copy", "--to", to,
2423
// --impure makes NIXPKGS_ALLOW_* environment variables work.
2524
"--impure",
@@ -35,5 +34,5 @@ func CopyInstallableToCache(
3534
cmd.Stderr = out
3635
cmd.Env = append(allowUnfreeEnv(allowInsecureEnv(os.Environ())), env...)
3736

38-
return cmd.Run()
37+
return cmd.Run(ctx)
3938
}

internal/nix/command.go

Lines changed: 243 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,258 @@
11
package nix
22

33
import (
4+
"bytes"
45
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
510
"os/exec"
11+
"slices"
12+
"strconv"
13+
"strings"
14+
"sync"
15+
"syscall"
16+
"time"
617
)
718

8-
func command(args ...string) *exec.Cmd {
9-
return commandContext(context.Background(), args...)
19+
type cmd struct {
20+
Args cmdArgs
21+
Env []string
22+
23+
Stdin io.Reader
24+
Stdout io.Writer
25+
Stderr io.Writer
26+
27+
execCmd *exec.Cmd
28+
execCmdOnce sync.Once
1029
}
1130

12-
func commandContext(ctx context.Context, args ...string) *exec.Cmd {
13-
cmd := exec.CommandContext(ctx, "nix", args...)
14-
cmd.Args = append(cmd.Args, ExperimentalFlags()...)
31+
func command(args ...any) *cmd {
32+
cmd := &cmd{
33+
Args: append(cmdArgs{
34+
"nix",
35+
"--extra-experimental-features", "ca-derivations",
36+
"--option", "experimental-features", "nix-command flakes fetch-closure",
37+
}, args...),
38+
}
1539
return cmd
1640
}
1741

42+
func (c *cmd) CombinedOutput(ctx context.Context) ([]byte, error) {
43+
out, err := c.initExecCommand(ctx).CombinedOutput()
44+
return out, c.error(ctx, err)
45+
}
46+
47+
func (c *cmd) Output(ctx context.Context) ([]byte, error) {
48+
out, err := c.initExecCommand(ctx).Output()
49+
return out, c.error(ctx, err)
50+
}
51+
52+
func (c *cmd) Run(ctx context.Context) error {
53+
return c.error(ctx, c.initExecCommand(ctx).Run())
54+
}
55+
56+
func (c *cmd) String() string {
57+
return c.Args.String()
58+
}
59+
60+
func (c *cmd) initExecCommand(ctx context.Context) *exec.Cmd {
61+
c.execCmdOnce.Do(func() {
62+
args := c.Args.StringSlice()
63+
c.execCmd = exec.CommandContext(ctx, args[0], args[1:]...)
64+
c.execCmd.Env = c.Env
65+
c.execCmd.Stdin = c.Stdin
66+
c.execCmd.Stdout = c.Stdout
67+
c.execCmd.Stderr = c.Stderr
68+
69+
c.execCmd.Cancel = func() error {
70+
// Try to let Nix exit gracefully by sending an
71+
// interrupt instead of the default behavior of killing
72+
// it.
73+
err := c.execCmd.Process.Signal(os.Interrupt)
74+
if errors.Is(err, os.ErrProcessDone) {
75+
// Nix already exited; execCmd.Wait will use the
76+
// exit code.
77+
return err
78+
}
79+
if err != nil {
80+
// We failed to send SIGINT, so kill the process
81+
// instead.
82+
//
83+
// - If Nix already exited, Kill will return
84+
// os.ErrProcessDone and execCmd.Wait will use
85+
// the exit code.
86+
// - Otherwise, execCmd.Wait will always return
87+
// an error.
88+
return c.execCmd.Process.Kill()
89+
}
90+
91+
// We sent the SIGINT successfully. It's still possible
92+
// for Nix to exit successfully, so return
93+
// os.ErrProcessDone so that execCmd.Wait uses the exit
94+
// code instead of ctx.Err.
95+
return os.ErrProcessDone
96+
}
97+
// Kill Nix if it doesn't exit within 15 seconds of Devbox
98+
// sending an interrupt.
99+
c.execCmd.WaitDelay = 15 * time.Second
100+
})
101+
return c.execCmd
102+
}
103+
104+
func (c *cmd) error(ctx context.Context, err error) error {
105+
if err == nil {
106+
return nil
107+
}
108+
109+
cmdErr := &cmdError{err: err}
110+
if errors.Is(err, exec.ErrNotFound) {
111+
cmdErr.msg = fmt.Sprintf("nix: %s not found in $PATH", c.Args[0])
112+
}
113+
114+
switch {
115+
case errors.Is(ctx.Err(), context.Canceled):
116+
cmdErr.msg = "nix: command canceled"
117+
case errors.Is(ctx.Err(), context.DeadlineExceeded):
118+
cmdErr.msg = "nix: command timed out"
119+
default:
120+
cmdErr.msg = "nix: command error"
121+
}
122+
cmdErr.msg += ": " + c.String()
123+
124+
var exitErr *exec.ExitError
125+
if errors.As(err, &exitErr) {
126+
if stderr := c.stderrExcerpt(exitErr.Stderr); len(stderr) != 0 {
127+
cmdErr.msg += ": " + stderr
128+
}
129+
if exitErr.Exited() {
130+
cmdErr.msg += fmt.Sprintf(": exit code %d", exitErr.ExitCode())
131+
return cmdErr
132+
}
133+
if stat, ok := exitErr.Sys().(syscall.WaitStatus); ok && stat.Signaled() {
134+
cmdErr.msg += fmt.Sprintf(": exit due to signal %d (%[1]s)", stat.Signal())
135+
return cmdErr
136+
}
137+
}
138+
139+
if !errors.Is(err, ctx.Err()) {
140+
cmdErr.msg += ": " + err.Error()
141+
}
142+
return cmdErr
143+
}
144+
145+
func (*cmd) stderrExcerpt(stderr []byte) string {
146+
stderr = bytes.TrimSpace(stderr)
147+
if len(stderr) == 0 {
148+
return ""
149+
}
150+
151+
lines := bytes.Split(stderr, []byte("\n"))
152+
slices.Reverse(lines)
153+
for _, line := range lines {
154+
line = bytes.TrimSpace(line)
155+
after, found := bytes.CutPrefix(line, []byte("error: "))
156+
if !found {
157+
continue
158+
}
159+
after = bytes.TrimSpace(after)
160+
if len(after) == 0 {
161+
continue
162+
}
163+
stderr = after
164+
break
165+
166+
}
167+
168+
excerpt := string(stderr)
169+
if !strconv.CanBackquote(excerpt) {
170+
quoted := strconv.Quote(excerpt)
171+
excerpt = quoted[1 : len(quoted)-1]
172+
}
173+
return excerpt
174+
}
175+
176+
type cmdArgs []any
177+
178+
func appendArgs[E any](args cmdArgs, new []E) cmdArgs {
179+
for _, elem := range new {
180+
args = append(args, elem)
181+
}
182+
return args
183+
}
184+
185+
func (c cmdArgs) StringSlice() []string {
186+
s := make([]string, len(c))
187+
for i := range c {
188+
s[i] = fmt.Sprint(c[i])
189+
}
190+
return s
191+
}
192+
193+
func (c cmdArgs) String() string {
194+
if len(c) == 0 {
195+
return ""
196+
}
197+
198+
sb := &strings.Builder{}
199+
c.writeQuoted(sb, fmt.Sprint(c[0]))
200+
if len(c) == 1 {
201+
return sb.String()
202+
}
203+
204+
for _, arg := range c[1:] {
205+
sb.WriteByte(' ')
206+
c.writeQuoted(sb, fmt.Sprint(arg))
207+
}
208+
return sb.String()
209+
}
210+
211+
func (cmdArgs) writeQuoted(dst *strings.Builder, str string) {
212+
needsQuote := strings.ContainsAny(str, ";\"'()$|&><` \t\r\n\\#{~*?[=")
213+
if !needsQuote {
214+
dst.WriteString(str)
215+
return
216+
}
217+
218+
canSingleQuote := !strings.Contains(str, "'")
219+
if canSingleQuote {
220+
dst.WriteByte('\'')
221+
dst.WriteString(str)
222+
dst.WriteByte('\'')
223+
return
224+
}
225+
226+
dst.WriteByte('"')
227+
for _, r := range str {
228+
switch r {
229+
// Special characters inside double quotes:
230+
// https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03
231+
case '$', '`', '"', '\\':
232+
dst.WriteRune('\\')
233+
}
234+
dst.WriteRune(r)
235+
}
236+
dst.WriteByte('"')
237+
}
238+
239+
type cmdError struct {
240+
msg string
241+
err error
242+
}
243+
244+
func (c *cmdError) Redact() string {
245+
return c.Error()
246+
}
247+
248+
func (c *cmdError) Error() string {
249+
return c.msg
250+
}
251+
252+
func (c *cmdError) Unwrap() error {
253+
return c.err
254+
}
255+
18256
func allowUnfreeEnv(curEnv []string) []string {
19257
return append(curEnv, "NIXPKGS_ALLOW_UNFREE=1")
20258
}

internal/nix/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ type ConfigField[T any] struct {
3737
func CurrentConfig(ctx context.Context) (Config, error) {
3838
// `nix show-config` is deprecated in favor of `nix config show`, but we
3939
// want to remain compatible with older Nix versions.
40-
cmd := commandContext(ctx, "show-config", "--json")
41-
out, err := cmd.Output()
40+
cmd := command("show-config", "--json")
41+
out, err := cmd.Output(ctx)
4242
var exitErr *exec.ExitError
4343
if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 {
4444
return Config{}, redact.Errorf("command %s: %v: %s", redact.Safe(cmd), err, exitErr.Stderr)

internal/nix/eval.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package nix
22

33
import (
4+
"context"
45
"encoding/json"
56
"os"
67
"strconv"
78
)
89

910
func EvalPackageName(path string) (string, error) {
1011
cmd := command("eval", "--raw", path+".name")
11-
out, err := cmd.Output()
12+
out, err := cmd.Output(context.TODO())
1213
if err != nil {
1314
return "", err
1415
}
@@ -18,7 +19,7 @@ func EvalPackageName(path string) (string, error) {
1819
// PackageIsInsecure is a fun little nix eval that maybe works.
1920
func PackageIsInsecure(path string) bool {
2021
cmd := command("eval", path+".meta.insecure")
21-
out, err := cmd.Output()
22+
out, err := cmd.Output(context.TODO())
2223
if err != nil {
2324
// We can't know for sure, but probably not.
2425
return false
@@ -33,7 +34,7 @@ func PackageIsInsecure(path string) bool {
3334

3435
func PackageKnownVulnerabilities(path string) []string {
3536
cmd := command("eval", path+".meta.knownVulnerabilities")
36-
out, err := cmd.Output()
37+
out, err := cmd.Output(context.TODO())
3738
if err != nil {
3839
// We can't know for sure, but probably not.
3940
return nil
@@ -51,7 +52,7 @@ func PackageKnownVulnerabilities(path string) []string {
5152
// to determine if a package if a package can be installed in system.
5253
func Eval(path string) ([]byte, error) {
5354
cmd := command("eval", "--raw", path)
54-
return cmd.CombinedOutput()
55+
return cmd.CombinedOutput(context.TODO())
5556
}
5657

5758
func AllowInsecurePackages() {

0 commit comments

Comments
 (0)