diff --git a/.changeset/common-jokes-hide.md b/.changeset/common-jokes-hide.md new file mode 100644 index 00000000000..9ade9ec5a59 --- /dev/null +++ b/.changeset/common-jokes-hide.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix Spawn ETXTBSY bug with multiple Functions using trampoline binaries diff --git a/packages/app/src/cli/services/function/binaries.test.ts b/packages/app/src/cli/services/function/binaries.test.ts index 690e7eab186..4cb860f8191 100644 --- a/packages/app/src/cli/services/function/binaries.test.ts +++ b/packages/app/src/cli/services/function/binaries.test.ts @@ -11,7 +11,7 @@ import { V2_TRAMPOLINE_VERSION, } from './binaries.js' import {fetch, Response} from '@shopify/cli-kit/node/http' -import {fileExists, removeFile} from '@shopify/cli-kit/node/fs' +import {fileExists, removeFile, writeFile} from '@shopify/cli-kit/node/fs' import {describe, expect, test, vi} from 'vitest' import {gzipSync} from 'zlib' @@ -174,6 +174,52 @@ describe('javy', () => { expect(fetch).toHaveBeenCalledOnce() await expect(fileExists(javy.path)).resolves.toBeTruthy() }) + + test('does not return early when file exists but rename is in progress', async () => { + // Given + await removeFile(javy.path) + await expect(fileExists(javy.path)).resolves.toBeFalsy() + + let resolveDownload!: () => void + const blockDownload = new Promise((resolve) => { + resolveDownload = resolve + }) + + vi.mocked(fetch).mockImplementation(async () => { + await blockDownload + return new Response(gzipSync('javy binary')) + }) + + // When + // Start first download — will block at fetch + const firstDownload = downloadBinary(javy) + + // Allow first download to reach fetch and register in downloadsInProgress + await new Promise((resolve) => setTimeout(resolve, 1)) + + // Simulate file appearing on disk (e.g., non-atomic moveFile creating the destination + // while the download is still tracked as in-progress) + await writeFile(javy.path, 'incomplete binary') + + // Start second download — should wait for first, not return early + let secondResolved = false + const secondDownload = downloadBinary(javy).then(() => { + secondResolved = true + }) + await new Promise((resolve) => setTimeout(resolve, 1)) + + // Then — second download should be blocked waiting for first + expect(secondResolved).toBe(false) + + // Complete the first download + resolveDownload() + await Promise.all([firstDownload, secondDownload]) + + // Only one fetch should have been made + expect(fetch).toHaveBeenCalledOnce() + expect(secondResolved).toBe(true) + await expect(fileExists(javy.path)).resolves.toBeTruthy() + }) }) describe('javy-plugin', () => { diff --git a/packages/app/src/cli/services/function/binaries.ts b/packages/app/src/cli/services/function/binaries.ts index 8e539d46953..6fa944a35a3 100644 --- a/packages/app/src/cli/services/function/binaries.ts +++ b/packages/app/src/cli/services/function/binaries.ts @@ -230,7 +230,11 @@ export function trampolineBinary(version: string) { const downloadsInProgress = new Map>() export async function downloadBinary(bin: DownloadableBinary) { - const isDownloaded = await fileExists(bin.path) + // If the file exists but its download is still in progress, the file cannot be used yet and we + // must wait for the download process to finish first. + // The `downloadsInProgress` check must happen after the async operation so the next `get` check + // runs in the same microtask. + const isDownloaded = (await fileExists(bin.path)) && !downloadsInProgress.has(bin.path) if (isDownloaded) { return }