Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/common-jokes-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Fix Spawn ETXTBSY bug with multiple Functions using trampoline binaries
48 changes: 47 additions & 1 deletion packages/app/src/cli/services/function/binaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<void>((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', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/cli/services/function/binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,11 @@ export function trampolineBinary(version: string) {
const downloadsInProgress = new Map<string, Promise<void>>()

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
}
Expand Down
Loading