Skip to content

Commit 2520541

Browse files
committed
Fix Spawn ETXTBSY bug for projects with multiple Functions
1 parent c3e54be commit 2520541

3 files changed

Lines changed: 57 additions & 2 deletions

File tree

.changeset/common-jokes-hide.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 Spawn ETXTBSY bug with multiple Functions using trampoline binaries

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
V2_TRAMPOLINE_VERSION,
1212
} from './binaries.js'
1313
import {fetch, Response} from '@shopify/cli-kit/node/http'
14-
import {fileExists, removeFile} from '@shopify/cli-kit/node/fs'
14+
import {fileExists, removeFile, writeFile} from '@shopify/cli-kit/node/fs'
1515
import {describe, expect, test, vi} from 'vitest'
1616
import {gzipSync} from 'zlib'
1717

@@ -174,6 +174,52 @@ describe('javy', () => {
174174
expect(fetch).toHaveBeenCalledOnce()
175175
await expect(fileExists(javy.path)).resolves.toBeTruthy()
176176
})
177+
178+
test('does not return early when file exists but rename is in progress', async () => {
179+
// Given
180+
await removeFile(javy.path)
181+
await expect(fileExists(javy.path)).resolves.toBeFalsy()
182+
183+
let resolveDownload!: () => void
184+
const blockDownload = new Promise<void>((resolve) => {
185+
resolveDownload = resolve
186+
})
187+
188+
vi.mocked(fetch).mockImplementation(async () => {
189+
await blockDownload
190+
return new Response(gzipSync('javy binary'))
191+
})
192+
193+
// When
194+
// Start first download — will block at fetch
195+
const firstDownload = downloadBinary(javy)
196+
197+
// Allow first download to reach fetch and register in downloadsInProgress
198+
await new Promise((resolve) => setTimeout(resolve, 1))
199+
200+
// Simulate file appearing on disk (e.g., non-atomic moveFile creating the destination
201+
// while the download is still tracked as in-progress)
202+
await writeFile(javy.path, 'incomplete binary')
203+
204+
// Start second download — should wait for first, not return early
205+
let secondResolved = false
206+
const secondDownload = downloadBinary(javy).then(() => {
207+
secondResolved = true
208+
})
209+
await new Promise((resolve) => setTimeout(resolve, 1))
210+
211+
// Then — second download should be blocked waiting for first
212+
expect(secondResolved).toBe(false)
213+
214+
// Complete the first download
215+
resolveDownload()
216+
await Promise.all([firstDownload, secondDownload])
217+
218+
// Only one fetch should have been made
219+
expect(fetch).toHaveBeenCalledOnce()
220+
expect(secondResolved).toBe(true)
221+
await expect(fileExists(javy.path)).resolves.toBeTruthy()
222+
})
177223
})
178224

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

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,11 @@ export function trampolineBinary(version: string) {
230230
const downloadsInProgress = new Map<string, Promise<void>>()
231231

232232
export async function downloadBinary(bin: DownloadableBinary) {
233-
const isDownloaded = await fileExists(bin.path)
233+
// If the file exists but its download is still in progress, the file cannot be used yet and we
234+
// must wait for the download process to finish first.
235+
// The `downloadsInProgress` check must happen after the async operation so the next `get` check
236+
// runs in the same microtask.
237+
const isDownloaded = (await fileExists(bin.path)) && !downloadsInProgress.has(bin.path)
234238
if (isDownloaded) {
235239
return
236240
}

0 commit comments

Comments
 (0)