Skip to content

Commit f3a515e

Browse files
committed
cmd/cue: do not ignore Encoder.Close errors on export
Typically, this is not a big deal: "cue export" writes to stdout, so the output is streamed to stdout directly, which should not fail. However, when using "cue export -o", we buffer the entire output and Encoder.Close actually writes it to the filename via os.WriteFile. Ignoring the error from os.WriteFile means we are ignoring any error related to creating the file, writing to it, or flushing/closing it. Stop ignoring the error by doing what other commands like def do. Note that they don't defer closing the encoder, which seems okay; all that Close does today is the equivalent of os.WriteFile, so there aren't any resources like a file descriptor to release. This fixes the first test case in the previous commit, inverting the expected outcome to match our expectations. While here, I also noticed a related issue with the writer func, which is what constructs the only non-nil Encoder.Close function value. The output "-o" flag comes with a "-f" flag to force overwriting existing files, which was implemented via an upfront call to os.Stat. This was racy: if I make a call to "cue export -o out.json", and evaluating and exporting my CUE takes around one second, it could be that a different process has created the file after the initial os.Stat call but before the final os.WriteFile call. "cue export -o" thus overwrites the file even without the "-f" flag. Instead, implement the logic to allow or disallow overwriting a file by using different os.FileModes and calling os.OpenFile directly, rather than using the simpler and higher level os.WriteFile. Without "-f", we allow creating a file with the mode bit os.O_EXCL, so we get the underlying error fs.ErrExist if the file already exists. With "-f", we use os.O_TRUNC instead, so we allow replacing an existing file and truncate it to zero bytes before writing - like os.WriteFile. This fixes the second test case in the previous commit, uncommenting the relevant lines since the behavior is no longer racy. Signed-off-by: Daniel Martí <[email protected]> Change-Id: Ia78ebf5569d5bbf1dac4d660bcb2d520d97be680 Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1169709 TryBot-Result: CUEcueckoo <[email protected]> Unity-Result: CUE porcuepine <[email protected]> Reviewed-by: Marcel van Lohuizen <[email protected]>
1 parent 388f9a1 commit f3a515e

File tree

3 files changed

+31
-19
lines changed

3 files changed

+31
-19
lines changed

cmd/cue/cmd/export.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ func runExport(cmd *Command, args []string) error {
115115

116116
enc, err := encoding.NewEncoder(b.outFile, b.encConfig)
117117
exitOnErr(cmd, err, true)
118-
defer enc.Close()
119118

120119
iter := b.instances()
121120
defer iter.close()
@@ -125,5 +124,9 @@ func runExport(cmd *Command, args []string) error {
125124
exitOnErr(cmd, err, true)
126125
}
127126
exitOnErr(cmd, iter.err(), true)
127+
128+
err = enc.Close()
129+
exitOnErr(cmd, err, true)
130+
128131
return nil
129132
}

cmd/cue/cmd/testdata/script/export_force.txtar

+7-9
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,20 @@ exec cue export --force -o test.yml file.cue
1313
cmp test.yml test.yml.golden
1414

1515
# With or without --force, we should fail to output to a file inside a missing directory.
16-
# TODO: these should not succeed; the following commit fixes the bug.
17-
exec cue export -o /definitely/does/not/exist/test.yml file.cue
18-
exec cue export --force -o /definitely/does/not/exist/test.yml file.cue
16+
! exec cue export -o /definitely/does/not/exist/test.yml file.cue
17+
! exec cue export --force -o /definitely/does/not/exist/test.yml file.cue
1918

2019
# Two concurrent exports to the same new file without --force;
2120
# only one should succeed. We use a relatively slow bit of CUE
2221
# to make it likely that both export operations begin before either
2322
# has finished and created the resulting output file.
2423
# Since it's a coin toss which command wins the race,
2524
# we allow both to fail but expect the joint stderr to contain exactly one error.
26-
# TODO: this is currently racy; the following commit fixes the bug.
27-
# exec cue_exitzero export -o conflict.yml slow.cue &
28-
# exec cue_exitzero export -o conflict.yml slow.cue &
29-
# wait
30-
# stderr -count=1 'error writing "conflict.yml": file already exists'
31-
# exists conflict.yml
25+
exec cue_exitzero export -o conflict.yml slow.cue &
26+
exec cue_exitzero export -o conflict.yml slow.cue &
27+
wait
28+
stderr -count=1 'error writing "conflict.yml": file already exists'
29+
exists conflict.yml
3230

3331
# Now with --force; the two commands should always succeed.
3432
exec cue export --force -o conflict_force.yml slow.cue &

internal/encoding/encoder.go

+20-9
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"encoding/json"
2020
"fmt"
2121
"io"
22+
"io/fs"
2223
"os"
2324
"path/filepath"
2425

@@ -300,17 +301,27 @@ func writer(f *build.File, cfg *Config) (_ io.Writer, close func() error, err er
300301
}
301302
return cfg.Stdout, nil, nil
302303
}
303-
if !cfg.Force {
304-
if _, err := os.Stat(path); err == nil {
305-
return nil, nil, errors.Wrapf(os.ErrExist, token.NoPos,
306-
"error writing %q", path)
307-
}
308-
}
309-
// Delay opening the file until we can write it to completion. This will
310-
// prevent clobbering the file in case of a crash.
304+
// Delay opening the file until we can write it to completion.
305+
// This prevents clobbering the file in case of a crash.
311306
b := &bytes.Buffer{}
312307
fn := func() error {
313-
return os.WriteFile(path, b.Bytes(), 0644)
308+
mode := os.O_WRONLY | os.O_CREATE | os.O_EXCL
309+
if cfg.Force {
310+
// Swap O_EXCL for O_TRUNC to allow replacing an entire existing file.
311+
mode = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
312+
}
313+
f, err := os.OpenFile(path, mode, 0o644)
314+
if err != nil {
315+
if errors.Is(err, fs.ErrExist) {
316+
return errors.Wrapf(fs.ErrExist, token.NoPos, "error writing %q", path)
317+
}
318+
return err
319+
}
320+
_, err = f.Write(b.Bytes())
321+
if err1 := f.Close(); err1 != nil && err == nil {
322+
err = err1
323+
}
324+
return err
314325
}
315326
return b, fn, nil
316327
}

0 commit comments

Comments
 (0)