From 0cbea2bcb5ada2fa71d0bd94c6e450dba70710a8 Mon Sep 17 00:00:00 2001 From: Dave Nagoda Date: Thu, 30 Apr 2026 15:40:51 -0700 Subject: [PATCH] Preload trampoline binaries to avoid race conditions when lazily downloading during parallel builds --- .changeset/preload-trampoline-binaries.md | 5 +++ packages/app/src/cli/services/build.ts | 10 +++-- .../app/src/cli/services/deploy/bundle.ts | 10 +++-- .../dev/processes/draftable-extension.ts | 5 --- .../dev/processes/setup-dev-processes.ts | 8 ++++ .../src/cli/services/function/build.test.ts | 40 +++++++++++++++++++ .../app/src/cli/services/function/build.ts | 16 ++++++++ 7 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 .changeset/preload-trampoline-binaries.md diff --git a/.changeset/preload-trampoline-binaries.md b/.changeset/preload-trampoline-binaries.md new file mode 100644 index 00000000000..eb6c097c4aa --- /dev/null +++ b/.changeset/preload-trampoline-binaries.md @@ -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 diff --git a/packages/app/src/cli/services/build.ts b/packages/app/src/cli/services/build.ts index e1b0e597f7a..37a1fa3619c 100644 --- a/packages/app/src/cli/services/build.ts +++ b/packages/app/src/cli/services/build.ts @@ -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' @@ -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: [ diff --git a/packages/app/src/cli/services/deploy/bundle.ts b/packages/app/src/cli/services/deploy/bundle.ts index 216645b51a0..093ced20f35 100644 --- a/packages/app/src/cli/services/deploy/bundle.ts +++ b/packages/app/src/cli/services/deploy/bundle.ts @@ -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' @@ -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 diff --git a/packages/app/src/cli/services/dev/processes/draftable-extension.ts b/packages/app/src/cli/services/dev/processes/draftable-extension.ts index a31ed3a11af..d80491b9dbb 100644 --- a/packages/app/src/cli/services/dev/processes/draftable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/draftable-extension.ts @@ -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' @@ -28,10 +27,6 @@ export const pushUpdatesForDraftableExtensions: DevProcessFunction { - // 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) => { diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 0c6ca0b2f6d..9add4c4d5ac 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -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' @@ -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 diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index 9d006530eff..c2f97b91c00 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -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, @@ -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) diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index 547401eb03a..90947f9f9e7 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -379,6 +379,22 @@ export async function installJavy(app: AppInterface) { await Promise.all(downloadPromises) } +export async function installTrampolines(app: AppInterface) { + 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, options: JSFunctionBuildOptions): Promise compile(