Skip to content
Closed
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/preload-trampoline-binaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Fix `shopify app build` intermittently failing with `ETXTBSY` ("text file busy") when building apps with multiple function extensions on a fresh environment
10 changes: 6 additions & 4 deletions packages/app/src/cli/services/build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import buildWeb from './web.js'
import {installAppDependencies} from './dependencies.js'
import {installJavy} from './function/build.js'
import {installJavy, installTrampolines} from './function/build.js'
import {AppInterface, Web} from '../models/app/app.js'
import {Project} from '../models/project/project.js'
import {renderConcurrent, renderSuccess} from '@shopify/cli-kit/node/ui'
Expand All @@ -24,9 +24,11 @@ async function build(options: BuildOptions) {
env.SHOPIFY_API_KEY = options.apiKey
}

// Force the download of the javy binary in advance to avoid later problems,
// as it might be done multiple times in parallel. https://github.com/Shopify/cli/issues/2877
await installJavy(options.app)
// Force the download of binaries in advance to avoid later problems,
// as it might be done multiple times in parallel.
// javy -> https://github.com/Shopify/cli/issues/2877
// trampoline -> ETXTBSY race conditions during parallel Rust builds
await Promise.all([installJavy(options.app), installTrampolines(options.app)])

await renderConcurrent({
processes: [
Expand Down
10 changes: 6 additions & 4 deletions packages/app/src/cli/services/deploy/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {AppInterface, AppManifest} from '../../models/app/app.js'
import {Identifiers} from '../../models/app/identifiers.js'
import {installJavy} from '../function/build.js'
import {installJavy, installTrampolines} from '../function/build.js'
import buildWeb from '../web.js'
import {compressBundle, writeManifestToBundle} from '../bundle.js'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
Expand Down Expand Up @@ -31,10 +31,12 @@ export async function bundleAndBuildExtensions(options: BundleOptions): Promise<

await writeManifestToBundle(options.appManifest, bundleDirectory)

// Force the download of the javy binary in advance to avoid later problems,
// as it might be done multiple times in parallel. https://github.com/Shopify/cli/issues/2877
// Force the download of binaries in advance to avoid later problems,
// as it might be done multiple times in parallel.
// javy -> https://github.com/Shopify/cli/issues/2877
// trampoline -> ETXTBSY race conditions during parallel Rust builds
if (!options.skipBuild) {
await installJavy(options.app)
await Promise.all([installJavy(options.app), installTrampolines(options.app)])
}

const webBuildProcesses = options.skipBuild
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {ExtensionInstance} from '../../../models/extensions/extension-instance.j
import {AppInterface} from '../../../models/app/app.js'
import {PartnersAppForIdentifierMatching, ensureDeploymentIdsPresence} from '../../context/identifiers.js'
import {getAppIdentifiers} from '../../../models/app/identifiers.js'
import {installJavy} from '../../function/build.js'
import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js'
import {AppEvent, AppEventWatcher, EventType} from '../app-events/app-event-watcher.js'
import {AbortError} from '@shopify/cli-kit/node/error'
Expand All @@ -28,10 +27,6 @@ export const pushUpdatesForDraftableExtensions: DevProcessFunction<DraftableExte
{stderr, stdout},
{developerPlatformClient, apiKey, remoteExtensionIds: remoteExtensions, localApp: app, appWatcher},
) => {
// Force the download of the javy binary in advance to avoid later problems,
// as it might be done multiple times in parallel. https://github.com/Shopify/cli/issues/2877
await installJavy(app)

const draftableExtensions = app.draftableExtensions.map((ext) => ext.handle)

const handleAppEvent = async (event: AppEvent) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {ApplicationURLs} from '../urls.js'
import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js'
import {AppEventWatcher} from '../app-events/app-event-watcher.js'
import {reloadApp} from '../../../models/app/loader.js'
import {installJavy, installTrampolines} from '../../function/build.js'
import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp'
import {isTruthy} from '@shopify/cli-kit/node/context/utilities'
import {firstPartyDev} from '@shopify/cli-kit/node/context/local'
Expand Down Expand Up @@ -97,6 +98,13 @@ export async function setupDevProcesses({

// At this point, the toml file has changed, we need to reload the app before actually starting dev
const reloadedApp = await reloadApp(localApp)

// Force the download of binaries in advance to avoid later problems,
// as it might be done multiple times in parallel.
// javy -> https://github.com/Shopify/cli/issues/2877
// trampoline -> ETXTBSY race conditions during parallel Rust builds
await Promise.all([installJavy(reloadedApp), installTrampolines(reloadedApp)])

const appWatcher = new AppEventWatcher(reloadedApp, network.proxyUrl)

// Decide on the appropriate preview URL for a session with these processes
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/cli/services/function/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
runWasmOpt,
runTrampoline,
buildJSFunction,
installTrampolines,
} from './build.js'
import {
javyBinary,
javyPluginBinary,
wasmOptBinary,
downloadBinary,
trampolineBinary,
PREFERRED_FUNCTION_RUNNER_VERSION,
PREFERRED_JAVY_VERSION,
Expand Down Expand Up @@ -455,6 +457,44 @@ describe('runTrampoline', () => {
})
})

describe('installTrampolines', () => {
test('downloads both trampoline versions when app has function extensions', async () => {
// Given
vi.mocked(downloadBinary).mockResolvedValue(undefined)
const functionExtension = await testFunctionExtension()
const appWithFunctions = testApp({allExtensions: [functionExtension]})

// When
await installTrampolines(appWithFunctions)

// Then
expect(downloadBinary).toHaveBeenCalledWith(trampolineBinary(V1_TRAMPOLINE_VERSION))
expect(downloadBinary).toHaveBeenCalledWith(trampolineBinary(V2_TRAMPOLINE_VERSION))
})

test('does not download trampolines when app has no function extensions', async () => {
// Given
const appWithoutFunctions = testApp({allExtensions: []})
vi.mocked(downloadBinary).mockClear()

// When
await installTrampolines(appWithoutFunctions)

// Then
expect(downloadBinary).not.toHaveBeenCalled()
})

test('swallows download errors for unsupported platform/arch combos', async () => {
// Given
vi.mocked(downloadBinary).mockRejectedValue(new Error('Unsupported platform/architecture combination'))
const functionExtension = await testFunctionExtension()
const appWithFunctions = testApp({allExtensions: [functionExtension]})

// When / Then — should not throw
await expect(installTrampolines(appWithFunctions)).resolves.toBeUndefined()
})
})

describe('ExportJavyBuilder', () => {
const exports = ['foo-bar', 'foo-baz']
const builder = new ExportJavyBuilder(exports)
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/cli/services/function/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,22 @@ export async function installJavy(app: AppInterface) {
await Promise.all(downloadPromises)
}

export async function installTrampolines(app: AppInterface) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One compatibility concern here that my 'bot found: installTrampolines() now eagerly downloads both trampoline versions, but trampolineBinary(version) only marks Windows-on-ARM support for versions >= 2.0.1.

That means on win32/arm64, a function app that only needs the v2 trampoline may have worked before (because runTrampoline() lazily selected/downloaded just v2), but could now fail up front trying to predownload the unused v1 binary.

Would it be safer to skip pre-downloading versions that are unsupported on the current platform/arch, or otherwise avoid failing the whole command on an unused trampoline version?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that makes sense. Let me see how we check for the needed version and look into adding that logic here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't determine which trampolines we need during the preload because we don't have WASMs to introspect, but we can protect from download errors and log. This will protect us from having issues during the preload. Thanks for catching this. 👍

const hasFunctionExtensions = app.allExtensions.some((ext) => ext.features.includes('function'))
if (!hasFunctionExtensions) {
return
}

await Promise.all([
downloadBinary(trampolineBinary(V1_TRAMPOLINE_VERSION)).catch((err) => {
outputDebug(`Failed to preload trampoline v${V1_TRAMPOLINE_VERSION}: ${err.message}`)
}),
downloadBinary(trampolineBinary(V2_TRAMPOLINE_VERSION)).catch((err) => {
outputDebug(`Failed to preload trampoline v${V2_TRAMPOLINE_VERSION}: ${err.message}`)
}),
])
}

interface JavyBuilder {
bundle(fun: ExtensionInstance<FunctionConfigType>, options: JSFunctionBuildOptions): Promise<BuildResult>
compile(
Expand Down
Loading