Skip to content

Commit 2f6dd92

Browse files
Jay Conrodandybons
authored andcommitted
[release-branch.go1.13] cmd/go: make module zip extraction more robust
Currently, we extract module zip files to temporary directories, then atomically rename them into place. On Windows, this can fail with ERROR_ACCESS_DENIED if another process (antivirus) has files open before the rename. In CL 220978, we repeated the rename operation in a loop over 500 ms, but this didn't solve the problem for everyone. A better solution will extract module zip files to their permanent locations in the cache and will keep a ".partial" marker file, indicating when a module hasn't been fully extracted (CL 221157). This approach is not safe if current versions of Go access the module cache concurrently, since the module directory is detected with a single os.Stat. In the interim, this CL makes two changes: 1. Flaky file system operations are repeated over 2000 ms to reduce the chance of this error occurring. 2. cmd/go will now check for .partial files created by future versions. If a .partial file is found, it will lock the lock file, then remove the .partial file and directory if needed. After some time has passed and Go versions lacking this CL are no longer supported, we can start extracting module zip files in place. Updates #37802 Change-Id: I467ee11aa59a90b63cf0e3e761c4fec89d57d3b6 Reviewed-on: https://go-review.googlesource.com/c/go/+/221820 Run-TryBot: Jay Conrod <[email protected]> TryBot-Result: Gobot Gobot <[email protected]> Reviewed-by: Michael Matloob <[email protected]> Reviewed-by: Bryan C. Mills <[email protected]> (cherry picked from commit 093049b) Reviewed-on: https://go-review.googlesource.com/c/go/+/223146
1 parent b79c36d commit 2f6dd92

File tree

6 files changed

+141
-34
lines changed

6 files changed

+141
-34
lines changed

src/cmd/go/internal/modcmd/verify.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package modcmd
77
import (
88
"bytes"
99
"cmd/go/internal/cfg"
10+
"errors"
1011
"fmt"
1112
"io/ioutil"
1213
"os"
@@ -61,12 +62,10 @@ func verifyMod(mod module.Version) bool {
6162
_, zipErr = os.Stat(zip)
6263
}
6364
dir, dirErr := modfetch.DownloadDir(mod)
64-
if dirErr == nil {
65-
_, dirErr = os.Stat(dir)
66-
}
6765
data, err := ioutil.ReadFile(zip + "hash")
6866
if err != nil {
69-
if zipErr != nil && os.IsNotExist(zipErr) && dirErr != nil && os.IsNotExist(dirErr) {
67+
if zipErr != nil && errors.Is(zipErr, os.ErrNotExist) &&
68+
dirErr != nil && errors.Is(dirErr, os.ErrNotExist) {
7069
// Nothing downloaded yet. Nothing to verify.
7170
return true
7271
}
@@ -75,7 +74,7 @@ func verifyMod(mod module.Version) bool {
7574
}
7675
h := string(bytes.TrimSpace(data))
7776

78-
if zipErr != nil && os.IsNotExist(zipErr) {
77+
if zipErr != nil && errors.Is(zipErr, os.ErrNotExist) {
7978
// ok
8079
} else {
8180
hZ, err := dirhash.HashZip(zip, dirhash.DefaultHash)
@@ -87,7 +86,7 @@ func verifyMod(mod module.Version) bool {
8786
ok = false
8887
}
8988
}
90-
if dirErr != nil && os.IsNotExist(dirErr) {
89+
if dirErr != nil && errors.Is(dirErr, os.ErrNotExist) {
9190
// ok
9291
} else {
9392
hD, err := dirhash.HashDir(dir, mod.Path+"@"+mod.Version, dirhash.DefaultHash)

src/cmd/go/internal/modfetch/cache.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package modfetch
77
import (
88
"bytes"
99
"encoding/json"
10+
"errors"
1011
"fmt"
1112
"io"
1213
"io/ioutil"
@@ -57,8 +58,11 @@ func CachePath(m module.Version, suffix string) (string, error) {
5758
return filepath.Join(dir, encVer+"."+suffix), nil
5859
}
5960

60-
// DownloadDir returns the directory to which m should be downloaded.
61-
// Note that the directory may not yet exist.
61+
// DownloadDir returns the directory to which m should have been downloaded.
62+
// An error will be returned if the module path or version cannot be escaped.
63+
// An error satisfying errors.Is(err, os.ErrNotExist) will be returned
64+
// along with the directory if the directory does not exist or if the directory
65+
// is not completely populated.
6266
func DownloadDir(m module.Version) (string, error) {
6367
if PkgMod == "" {
6468
return "", fmt.Errorf("internal error: modfetch.PkgMod not set")
@@ -77,9 +81,39 @@ func DownloadDir(m module.Version) (string, error) {
7781
if err != nil {
7882
return "", err
7983
}
80-
return filepath.Join(PkgMod, enc+"@"+encVer), nil
84+
85+
dir := filepath.Join(PkgMod, enc+"@"+encVer)
86+
if fi, err := os.Stat(dir); os.IsNotExist(err) {
87+
return dir, err
88+
} else if err != nil {
89+
return dir, &DownloadDirPartialError{dir, err}
90+
} else if !fi.IsDir() {
91+
return dir, &DownloadDirPartialError{dir, errors.New("not a directory")}
92+
}
93+
partialPath, err := CachePath(m, "partial")
94+
if err != nil {
95+
return dir, err
96+
}
97+
if _, err := os.Stat(partialPath); err == nil {
98+
return dir, &DownloadDirPartialError{dir, errors.New("not completely extracted")}
99+
} else if !os.IsNotExist(err) {
100+
return dir, err
101+
}
102+
return dir, nil
103+
}
104+
105+
// DownloadDirPartialError is returned by DownloadDir if a module directory
106+
// exists but was not completely populated.
107+
//
108+
// DownloadDirPartialError is equivalent to os.ErrNotExist.
109+
type DownloadDirPartialError struct {
110+
Dir string
111+
Err error
81112
}
82113

114+
func (e *DownloadDirPartialError) Error() string { return fmt.Sprintf("%s: %v", e.Dir, e.Err) }
115+
func (e *DownloadDirPartialError) Is(err error) bool { return err == os.ErrNotExist }
116+
83117
// lockVersion locks a file within the module cache that guards the downloading
84118
// and extraction of the zipfile for the given module version.
85119
func lockVersion(mod module.Version) (unlock func(), err error) {

src/cmd/go/internal/modfetch/fetch.go

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package modfetch
77
import (
88
"archive/zip"
99
"bytes"
10+
"errors"
1011
"fmt"
1112
"io"
1213
"io/ioutil"
@@ -22,6 +23,7 @@ import (
2223
"cmd/go/internal/module"
2324
"cmd/go/internal/par"
2425
"cmd/go/internal/renameio"
26+
"cmd/go/internal/robustio"
2527
)
2628

2729
var downloadCache par.Cache
@@ -41,32 +43,35 @@ func Download(mod module.Version) (dir string, err error) {
4143
err error
4244
}
4345
c := downloadCache.Do(mod, func() interface{} {
44-
dir, err := DownloadDir(mod)
46+
dir, err := download(mod)
4547
if err != nil {
4648
return cached{"", err}
4749
}
48-
if err := download(mod, dir); err != nil {
49-
return cached{"", err}
50-
}
5150
checkMod(mod)
5251
return cached{dir, nil}
5352
}).(cached)
5453
return c.dir, c.err
5554
}
5655

57-
func download(mod module.Version, dir string) (err error) {
58-
// If the directory exists, the module has already been extracted.
59-
fi, err := os.Stat(dir)
60-
if err == nil && fi.IsDir() {
61-
return nil
56+
func download(mod module.Version) (dir string, err error) {
57+
// If the directory exists, and no .partial file exists,
58+
// the module has already been completely extracted.
59+
// .partial files may be created when future versions of cmd/go
60+
// extract module zip directories in place instead of extracting
61+
// to a random temporary directory and renaming.
62+
dir, err = DownloadDir(mod)
63+
if err == nil {
64+
return dir, nil
65+
} else if dir == "" || !errors.Is(err, os.ErrNotExist) {
66+
return "", err
6267
}
6368

6469
// To avoid cluttering the cache with extraneous files,
6570
// DownloadZip uses the same lockfile as Download.
6671
// Invoke DownloadZip before locking the file.
6772
zipfile, err := DownloadZip(mod)
6873
if err != nil {
69-
return err
74+
return "", err
7075
}
7176

7277
if cfg.CmdName != "mod download" {
@@ -75,17 +80,19 @@ func download(mod module.Version, dir string) (err error) {
7580

7681
unlock, err := lockVersion(mod)
7782
if err != nil {
78-
return err
83+
return "", err
7984
}
8085
defer unlock()
8186

8287
// Check whether the directory was populated while we were waiting on the lock.
83-
fi, err = os.Stat(dir)
84-
if err == nil && fi.IsDir() {
85-
return nil
88+
_, dirErr := DownloadDir(mod)
89+
if dirErr == nil {
90+
return dir, nil
8691
}
92+
_, dirExists := dirErr.(*DownloadDirPartialError)
8793

88-
// Clean up any remaining temporary directories from previous runs.
94+
// Clean up any remaining temporary directories from previous runs, as well
95+
// as partially extracted diectories created by future versions of cmd/go.
8996
// This is only safe to do because the lock file ensures that their writers
9097
// are no longer active.
9198
parentDir := filepath.Dir(dir)
@@ -95,18 +102,31 @@ func download(mod module.Version, dir string) (err error) {
95102
RemoveAll(path) // best effort
96103
}
97104
}
105+
if dirExists {
106+
if err := RemoveAll(dir); err != nil {
107+
return "", err
108+
}
109+
}
110+
111+
partialPath, err := CachePath(mod, "partial")
112+
if err != nil {
113+
return "", err
114+
}
115+
if err := os.Remove(partialPath); err != nil && !os.IsNotExist(err) {
116+
return "", err
117+
}
98118

99119
// Extract the zip file to a temporary directory, then rename it to the
100120
// final path. That way, we can use the existence of the source directory to
101121
// signal that it has been extracted successfully, and if someone deletes
102122
// the entire directory (e.g. as an attempt to prune out file corruption)
103123
// the module cache will still be left in a recoverable state.
104124
if err := os.MkdirAll(parentDir, 0777); err != nil {
105-
return err
125+
return "", err
106126
}
107127
tmpDir, err := ioutil.TempDir(parentDir, tmpPrefix)
108128
if err != nil {
109-
return err
129+
return "", err
110130
}
111131
defer func() {
112132
if err != nil {
@@ -117,17 +137,17 @@ func download(mod module.Version, dir string) (err error) {
117137
modpath := mod.Path + "@" + mod.Version
118138
if err := Unzip(tmpDir, zipfile, modpath, 0); err != nil {
119139
fmt.Fprintf(os.Stderr, "-> %s\n", err)
120-
return err
140+
return "", err
121141
}
122142

123-
if err := os.Rename(tmpDir, dir); err != nil {
124-
return err
143+
if err := robustio.Rename(tmpDir, dir); err != nil {
144+
return "", err
125145
}
126146

127147
// Make dir read-only only *after* renaming it.
128148
// os.Rename was observed to fail for read-only directories on macOS.
129149
makeDirsReadOnly(dir)
130-
return nil
150+
return dir, nil
131151
}
132152

133153
var downloadZipCache par.Cache

src/cmd/go/internal/modload/build.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,7 @@ func moduleInfo(m module.Version, fromBuildList bool) *modinfo.ModulePublic {
143143
}
144144
dir, err := modfetch.DownloadDir(mod)
145145
if err == nil {
146-
if info, err := os.Stat(dir); err == nil && info.IsDir() {
147-
m.Dir = dir
148-
}
146+
m.Dir = dir
149147
}
150148
}
151149
}

src/cmd/go/internal/robustio/robustio_windows.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
"time"
1515
)
1616

17-
const arbitraryTimeout = 500 * time.Millisecond
17+
const arbitraryTimeout = 2000 * time.Millisecond
1818

1919
// retry retries ephemeral errors from f up to an arbitrary timeout
2020
// to work around spurious filesystem errors on Windows
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Download a module
2+
go mod download rsc.io/quote
3+
exists $GOPATH/pkg/mod/rsc.io/[email protected]/go.mod
4+
5+
# 'go mod verify' should fail if we delete a file.
6+
go mod verify
7+
chmod 0755 $GOPATH/pkg/mod/rsc.io/[email protected]
8+
rm $GOPATH/pkg/mod/rsc.io/[email protected]/go.mod
9+
! go mod verify
10+
11+
# Create a .partial file to simulate an failure extracting the zip file.
12+
cp empty $GOPATH/pkg/mod/cache/download/rsc.io/quote/@v/v1.5.2.partial
13+
14+
# 'go mod verify' should not fail, since the module hasn't been completely
15+
# ingested into the cache.
16+
go mod verify
17+
18+
# 'go list' should not load packages from the directory.
19+
# NOTE: the message "directory $dir outside available modules" is reported
20+
# for directories not in the main module, active modules in the module cache,
21+
# or local replacements. In this case, the directory is in the right place,
22+
# but it's incomplete, so 'go list' acts as if it's not an active module.
23+
! go list $GOPATH/pkg/mod/rsc.io/[email protected]
24+
stderr 'outside available modules'
25+
26+
# 'go list -m' should not print the directory.
27+
go list -m -f '{{.Dir}}' rsc.io/quote
28+
! stdout .
29+
30+
# 'go mod download' should re-extract the module and remove the .partial file.
31+
go mod download rsc.io/quote
32+
! exists $GOPATH/pkg/mod/cache/download/rsc.io/quote/@v/v1.5.2.partial
33+
exists $GOPATH/pkg/mod/rsc.io/[email protected]/go.mod
34+
35+
# 'go list' should succeed.
36+
go list $GOPATH/pkg/mod/rsc.io/[email protected]
37+
stdout '^rsc.io/quote$'
38+
39+
# 'go list -m' should print the directory.
40+
go list -m -f '{{.Dir}}' rsc.io/quote
41+
stdout 'pkg[/\\]mod[/\\]rsc.io[/\\][email protected]'
42+
43+
# go mod verify should fail if we delete a file.
44+
go mod verify
45+
chmod 0755 $GOPATH/pkg/mod/rsc.io/[email protected]
46+
rm $GOPATH/pkg/mod/rsc.io/[email protected]/go.mod
47+
! go mod verify
48+
49+
-- go.mod --
50+
module m
51+
52+
go 1.14
53+
54+
require rsc.io/quote v1.5.2
55+
56+
-- empty --

0 commit comments

Comments
 (0)