Skip to content

Commit e53a9df

Browse files
authored
fix handling of concurrent package installs (#756)
1 parent 3f07af4 commit e53a9df

4 files changed

Lines changed: 189 additions & 14 deletions

File tree

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# packrat (development version)
22

3+
- Fixed a race condition where concurrent R processes installing the same
4+
package could fail while inserting it into the package cache.
5+
36
- Update vendored `renv` to support recognition of Posit Package Manager
47
support for manylinux binaries.
58

R/cache.R

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -251,26 +251,28 @@ moveInstalledPackageToCache <- function(
251251
tempPath <- tempfile(tmpdir = dirname(cachedPackagePath))
252252
on.exit(unlink(tempPath, recursive = TRUE), add = TRUE)
253253
if (all(dir_copy(packagePath, tempPath))) {
254-
# check to see if the cached package path exists now; if it does,
255-
# assume that this was generated by another R process that successfully
256-
# populated the cache
257-
if (file.exists(cachedPackagePath)) {
258-
return(symlinkPackageToCache(packagePath, cachedPackagePath))
259-
}
260-
261254
# attempt to rename to target path
262255
if (suppressWarnings(file.rename(tempPath, cachedPackagePath))) {
263256
return(symlinkPackageToCache(packagePath, cachedPackagePath))
264257
}
265258
}
266259

267-
# failed to insert package into cache -- clean up and return error
268-
if (!file.rename(backupPackagePath, cachedPackagePath)) {
269-
stop(
270-
"failed to restore package '",
271-
packageName,
272-
"' in cache; package may be lost from cache"
273-
)
260+
# failed to insert the package; if a competing process populated the cache
261+
# entry in the meantime, use it
262+
if (file.exists(cachedPackagePath)) {
263+
return(symlinkPackageToCache(packagePath, cachedPackagePath))
264+
}
265+
266+
# failed to insert package into cache -- restore any backed-up cache entry
267+
# and return error
268+
if (file.exists(backupPackagePath)) {
269+
if (!file.rename(backupPackagePath, cachedPackagePath)) {
270+
stop(
271+
"failed to restore package '",
272+
packageName,
273+
"' in cache; package may be lost from cache"
274+
)
275+
}
274276
}
275277

276278
# return failure

R/testthat-helpers.R

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ cloneTestProject <- function(projectName) {
1010
return(file.path(target, projectName))
1111
}
1212

13+
# Create a minimal fake installed package in its own temporary library;
14+
# returns the path to the package directory.
15+
makeTestPackage <- function(packageName, version = "1.0") {
16+
libRoot <- tempfile("packrat-lib-")
17+
packagePath <- file.path(libRoot, packageName)
18+
dir.create(packagePath, recursive = TRUE)
19+
writeLines(
20+
c(paste("Package:", packageName), paste("Version:", version)),
21+
file.path(packagePath, "DESCRIPTION")
22+
)
23+
packagePath
24+
}
25+
1326
# "Rebuilds" the test repo from its package "sources" (just DESCRIPTION files).
1427
rebuildTestRepo <- function(testroot = getwd()) {
1528
# Try to guess where the DESCRIPTION file lives (for R CMD check

tests/testthat/test-cache.R

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,163 @@ test_that("packrat warns when lockfile hash does not match installed hash", {
105105
)
106106
})
107107

108+
test_that("moveInstalledPackageToCache caches a fresh package", {
109+
skip_on_os("windows")
110+
111+
packagePath <- makeTestPackage("oatmeal")
112+
cacheDir <- tempfile("packrat-cache-")
113+
on.exit(
114+
unlink(c(dirname(packagePath), cacheDir), recursive = TRUE),
115+
add = TRUE
116+
)
117+
118+
hash <- strrep("a", 32)
119+
cachedPackagePath <- file.path(cacheDir, "oatmeal", hash, "oatmeal")
120+
121+
result <- moveInstalledPackageToCache(packagePath, hash, cacheDir = cacheDir)
122+
123+
expect_identical(result, cachedPackagePath)
124+
expect_true(is.symlink(packagePath))
125+
expect_true(file.exists(file.path(cachedPackagePath, "DESCRIPTION")))
126+
})
127+
128+
test_that("moveInstalledPackageToCache adopts a competing process's copy", {
129+
skip_on_os("windows")
130+
131+
packagePath <- makeTestPackage("oatmeal")
132+
cacheDir <- tempfile("packrat-cache-")
133+
on.exit(
134+
unlink(c(dirname(packagePath), cacheDir), recursive = TRUE),
135+
add = TRUE
136+
)
137+
138+
hash <- strrep("a", 32)
139+
cachedPackagePath <- file.path(cacheDir, "oatmeal", hash, "oatmeal")
140+
141+
# Simulate losing the race to populate the cache: renames into the final
142+
# cache location fail (as when a competing process created the destination
143+
# first), and the competitor's byte-identical copy appears between the
144+
# file.exists() check and the second rename.
145+
realRename <- base::file.rename
146+
renamesToCache <- 0
147+
local_mocked_bindings(
148+
file.rename = function(from, to) {
149+
if (!identical(to, cachedPackagePath)) {
150+
return(realRename(from, to))
151+
}
152+
renamesToCache <<- renamesToCache + 1
153+
if (renamesToCache == 2) {
154+
dir.create(cachedPackagePath, recursive = TRUE)
155+
writeLines(
156+
c("Package: oatmeal", "Version: 1.0"),
157+
file.path(cachedPackagePath, "DESCRIPTION")
158+
)
159+
}
160+
FALSE
161+
},
162+
.package = "base"
163+
)
164+
165+
result <- moveInstalledPackageToCache(packagePath, hash, cacheDir = cacheDir)
166+
167+
expect_identical(result, cachedPackagePath)
168+
expect_true(is.symlink(packagePath))
169+
expect_true(file.exists(file.path(packagePath, "DESCRIPTION")))
170+
})
171+
172+
test_that("moveInstalledPackageToCache reports a fresh-package cache failure", {
173+
skip_on_os("windows")
174+
175+
packagePath <- makeTestPackage("oatmeal")
176+
cacheDir <- tempfile("packrat-cache-")
177+
on.exit(
178+
unlink(c(dirname(packagePath), cacheDir), recursive = TRUE),
179+
add = TRUE
180+
)
181+
182+
hash <- strrep("a", 32)
183+
cachedPackagePath <- file.path(cacheDir, "oatmeal", hash, "oatmeal")
184+
185+
# All renames into the final cache location fail and no competing process
186+
# populates the cache. With no pre-existing cache entry there is nothing to
187+
# roll back, so the error should report the copy failure rather than a
188+
# bogus "package may be lost from cache".
189+
realRename <- base::file.rename
190+
local_mocked_bindings(
191+
file.rename = function(from, to) {
192+
if (!identical(to, cachedPackagePath)) {
193+
return(realRename(from, to))
194+
}
195+
FALSE
196+
},
197+
.package = "base"
198+
)
199+
200+
expect_error(
201+
moveInstalledPackageToCache(packagePath, hash, cacheDir = cacheDir),
202+
"failed to copy package 'oatmeal' to cache"
203+
)
204+
205+
# the original installation is untouched
206+
expect_false(is.symlink(packagePath))
207+
expect_true(file.exists(file.path(packagePath, "DESCRIPTION")))
208+
})
209+
210+
test_that("moveInstalledPackageToCache restores a backed-up cache entry", {
211+
skip_on_os("windows")
212+
213+
packagePath <- makeTestPackage("oatmeal", version = "2.0")
214+
cacheDir <- tempfile("packrat-cache-")
215+
on.exit(
216+
unlink(c(dirname(packagePath), cacheDir), recursive = TRUE),
217+
add = TRUE
218+
)
219+
220+
hash <- strrep("a", 32)
221+
cachedPackagePath <- file.path(cacheDir, "oatmeal", hash, "oatmeal")
222+
223+
# a pre-existing cache entry that overwrite = TRUE will back up
224+
dir.create(cachedPackagePath, recursive = TRUE)
225+
writeLines(
226+
c("Package: oatmeal", "Version: 1.0"),
227+
file.path(cachedPackagePath, "DESCRIPTION")
228+
)
229+
230+
# the first two renames into the final cache location (direct rename, then
231+
# rename of the temporary copy) fail; the third is the rollback of the
232+
# backup, which must be allowed through
233+
realRename <- base::file.rename
234+
renamesToCache <- 0
235+
local_mocked_bindings(
236+
file.rename = function(from, to) {
237+
if (!identical(to, cachedPackagePath)) {
238+
return(realRename(from, to))
239+
}
240+
renamesToCache <<- renamesToCache + 1
241+
if (renamesToCache <= 2) {
242+
return(FALSE)
243+
}
244+
realRename(from, to)
245+
},
246+
.package = "base"
247+
)
248+
249+
expect_error(
250+
moveInstalledPackageToCache(
251+
packagePath,
252+
hash,
253+
overwrite = TRUE,
254+
fatal = TRUE,
255+
cacheDir = cacheDir
256+
),
257+
"failed to copy package 'oatmeal' to cache"
258+
)
259+
260+
# the pre-existing cache entry was restored from backup
261+
desc <- readLines(file.path(cachedPackagePath, "DESCRIPTION"))
262+
expect_true("Version: 1.0" %in% desc)
263+
})
264+
108265
test_that("packrat uses the untrusted cache when instructed", {
109266
skip_on_cran()
110267
skip_on_os("windows")

0 commit comments

Comments
 (0)