Skip to content

Commit df23ee4

Browse files
authored
Merge pull request #6395 from Shopify/jc.fix-concurrency-in-binary-downloads
Fix concurrency issue in downloadBinary
2 parents 354801b + a06bd0d commit df23ee4

File tree

3 files changed

+65
-0
lines changed

3 files changed

+65
-0
lines changed

.changeset/healthy-coins-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Fix concurrency issue with downloading Functions binaries

packages/app/src/cli/services/function/binaries.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,26 @@ describe('javy', () => {
141141
expect(fetch).toHaveBeenCalledOnce()
142142
await expect(fileExists(javy.path)).resolves.toBeTruthy()
143143
})
144+
145+
test('handles concurrent downloads of Javy', async () => {
146+
// Given
147+
await removeFile(javy.path)
148+
await expect(fileExists(javy.path)).resolves.toBeFalsy()
149+
vi.mocked(fetch).mockResolvedValue(new Response(gzipSync('javy binary')))
150+
151+
// When
152+
await Promise.all([
153+
downloadBinary(javy),
154+
downloadBinary(javy),
155+
downloadBinary(javy),
156+
downloadBinary(javy),
157+
downloadBinary(javy),
158+
])
159+
160+
// Then
161+
expect(fetch).toHaveBeenCalledOnce()
162+
await expect(fileExists(javy.path)).resolves.toBeTruthy()
163+
})
144164
})
145165

146166
describe('javy-plugin', () => {

packages/app/src/cli/services/function/binaries.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,52 @@ export function trampolineBinary() {
203203
return _trampoline
204204
}
205205

206+
const downloadsInProgress = new Map<string, Promise<void>>()
207+
206208
export async function downloadBinary(bin: DownloadableBinary) {
207209
const isDownloaded = await fileExists(bin.path)
208210
if (isDownloaded) {
209211
return
210212
}
211213

214+
// Downloads cannot run concurrently with `exec` since the `moveFile`
215+
// operation is not atomic. It will delete the destination file if it exists
216+
// which will cause `exec` to break if it tries to execute the file before
217+
// the source file has been moved.
218+
// We prevent downloads from happening concurrently with `exec` by enforcing
219+
// that only one download happens for an executable. If we get here, we check
220+
// if a download is in progress, wait for it to finish, and return without
221+
// downloading again. If it's not in progress, then we start the download.
222+
const downloadPromise = downloadsInProgress.get(bin.path)
223+
if (downloadPromise) {
224+
await downloadPromise
225+
// Return now since we can assume it's downloaded. If it's not, an exception should've
226+
// been thrown before which will cause the user-level operation to fail anyway.
227+
return
228+
}
229+
230+
// Do not perform any `await`s until we've called `downloadsInProgress.set`.
231+
// Calling `await` before that can cause a different JS task to become active
232+
// and start a concurrent download for this binary.
233+
234+
// My mental model is `performDownload` without the `await` will run
235+
// synchronously until the first `await` in the function and then
236+
// immediately return the promise which we then immediately store. Since JS
237+
// doesn't have preemptive concurrency, we should be able to safely assume a
238+
// different task in the task queue will not run in between starting
239+
// `downloadFn` and the `set` operation on the following line.
240+
const downloadOp = performDownload(bin)
241+
downloadsInProgress.set(bin.path, downloadOp)
242+
// Ensure we clean the entry if there's a failure.
243+
try {
244+
// Wait for the download to finish
245+
await downloadOp
246+
} finally {
247+
downloadsInProgress.delete(bin.path)
248+
}
249+
}
250+
251+
async function performDownload(bin: DownloadableBinary) {
212252
const url = bin.downloadUrl(process.platform, process.arch)
213253
outputDebug(`Downloading ${bin.name} ${bin.version} from ${url} to ${bin.path}`)
214254
const dir = dirname(bin.path)

0 commit comments

Comments
 (0)