[Bug]: app deploy fails intermittently with ENOENT on <handle>.js.map when two UI extensions share a folder
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 67/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- node.js, typescript
Direzione di ricerca
Start with the supplied reproduction in packages/app/src/cli/services/deploy/shared-dist-race.test.ts; from packages/app, run pnpm vitest run src/cli/services/deploy/shared-dist-race.test.ts to confirm the race. Read packages/app/src/cli/services/deploy/bundle.ts, packages/app/src/cli/services/build/steps/bundle-ui-step.ts, and packages/app/src/cli/models/extensions/extension-instance.ts around the named functions. Done when the regression test passes and two UI extensions sharing a folder can bundle without the intermittent ENOENT.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
What happens
shopify app deploy fails now and then while bundling, and passes when re-run with nothing changed:
Build step "Bundle UI Extension" failed: ENOENT: no such file or directory,
lstat '<app>/extensions/<folder>/dist/<sibling-handle>.js.map'
It takes one shopify.extension.toml that declares two [[extensions]] of type ui_extension. Seen in CI with 4.8.5 (Linux, Node 24). The code involved is the same on main (1145c78e1).
Cause
Both extensions build into the same <folder>/dist/, and app deploy builds all extensions at the same time (bundleAndBuildExtensions).
executeBundleUIStepcopies the whole localdist/into the extension's bundle folder (since 0986095df, 3.94.0). fs-extra'scopyreads the folder, thenlstats its entries one by one.buildForBundlethen callskeepBuiltSourcemapsLocally, which moves the extension's own map from the bundle back into<folder>/dist/withmoveFile(..., {overwrite: true}). fs-extra'smoveremoves the destination, then renames.
While extension A is between "remove" and "rename", A.js.map is missing from the shared dist/. If extension B listed that folder just before, its copy reaches A.js.map, lstat throws and the deploy stops.
Reproduction
The test below creates a minimal app (one folder, two UI extensions) and runs bundleAndBuildExtensions on it. Save it as packages/app/src/cli/services/deploy/shared-dist-race.test.ts and, from packages/app, run:
pnpm vitest run src/cli/services/deploy/shared-dist-race.test.ts
By default it adds three delays, which change timing and nothing else, so that the interleaving above happens on every run: the second extension enters its build 300 ms late, a folder copy takes 150 ms longer to reach each map it listed, and the rename that puts a map back is held 1.5 s. FORCE=0 removes them.
| Code | Timing | Result |
|---|---|---|
| 4.8.5 | delays on | 10 of 10 deploys fail with the error above |
main (1145c78e1) |
delays on | 10 of 10 fail |
| 4.8.5 | natural (FORCE=0), this minimal app |
0 of 300 fail |
| 4.8.5 | natural, a real app with two such folders | 3 of 300 fail |
shared-dist-race.test.ts
// Repro: `app deploy` fails with ENOENT on <handle>.js.map when one shopify.extension.toml
// declares two UI extensions (both build into the same dist/ folder).
//
// Save as packages/app/src/cli/services/deploy/shared-dist-race.test.ts and, from packages/app:
// pnpm vitest run src/cli/services/deploy/shared-dist-race.test.ts
//
// By default three delays line the two builds up so every run fails. They change timing only:
// - the second extension enters its build SKEW_MS late (a slower build),
// - a folder copy takes STAT_DELAY_MS longer to reach each .js.map it listed,
// - the rename that puts a map back is held RENAME_HOLD_MS (its destination is already removed).
// FORCE=0 removes all three and loops at natural timing, where the failure is rare.
import {bundleAndBuildExtensions} from './bundle.js'
import {localAppContext} from '../app-context.js'
import {expect, test, vi} from 'vitest'
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
import {tmpdir} from 'node:os'
import {join} from 'node:path'
const forced = process.env.FORCE !== '0'
const SKEW_MS = forced ? 300 : 0
const iterations = Number(process.env.ITER ?? (forced ? 10 : 300))
// Runs before the imports: fs-extra (through graceful-fs) copies the fs functions when it loads.
vi.hoisted(() => {
if (process.env.FORCE === '0') return
const STAT_DELAY_MS = 150
const RENAME_HOLD_MS = 1500
const fs = process.getBuiltinModule('node:fs')
// a source map in an extension's own dist folder, not its copy inside the deploy bundle
const localMap = (file: unknown) =>
typeof file === 'string' && file.endsWith('.js.map') && !file.includes('/.shopify/')
const {lstat, rename} = fs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(fs as any).lstat = (...args: any[]) => {
if (localMap(args[0])) setTimeout(() => Reflect.apply(lstat, fs, args), STAT_DELAY_MS)
else Reflect.apply(lstat, fs, args)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(fs as any).rename = (...args: any[]) => {
if (localMap(args[1])) setTimeout(() => Reflect.apply(rename, fs, args), RENAME_HOLD_MS)
else Reflect.apply(rename, fs, args)
}
})
// Under vitest this plugin resolves to its ESM build, whose default export is the plugin itself;
// the CLI expects the CommonJS shape (`graphqlLoaderPlugin.default()`).
vi.mock('@luckycatfactory/esbuild-graphql-loader', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual: any = await importOriginal()
return {default: {default: actual.default?.default ?? actual.default}}
})
function createApp(): string {
const dir = mkdtempSync(join(tmpdir(), 'shared-dist-race-'))
writeFileSync(join(dir, 'package.json'), JSON.stringify({name: 'shared-dist-race', private: true}))
writeFileSync(
join(dir, 'shopify.app.toml'),
`client_id = "0123456789abcdef0123456789abcdef"
name = "shared-dist-race"
application_url = "https://example.com"
embedded = true
[access_scopes]
scopes = "read_orders"
[auth]
redirect_urls = ["https://example.com/auth/callback"]
[webhooks]
api_version = "2026-01"
`,
)
const extension = join(dir, 'extensions', 'two-blocks')
mkdirSync(join(extension, 'src'), {recursive: true})
// one folder, two UI extensions
writeFileSync(
join(extension, 'shopify.extension.toml'),
`api_version = "2026-01"
[[extensions]]
type = "ui_extension"
name = "First block"
handle = "first-block"
[[extensions.targeting]]
module = "./src/first.js"
target = "admin.order-details.block.render"
[[extensions]]
type = "ui_extension"
name = "Second block"
handle = "second-block"
[[extensions.targeting]]
module = "./src/second.js"
target = "admin.customer-details.block.render"
`,
)
writeFileSync(join(extension, 'src', 'first.js'), 'export default async () => {}\n')
writeFileSync(join(extension, 'src', 'second.js'), 'export default async () => {}\n')
return dir
}
test(
'two UI extensions in one folder deploy without racing on their shared dist/',
async () => {
const failures: string[] = []
for (let index = 0; index < iterations; index++) {
const directory = createApp()
const {app} = await localAppContext({directory, userProvidedConfigName: 'shopify.app.toml', skipPrompts: true})
if (SKEW_MS > 0) {
const late = app.allExtensions.find((extension) => extension.handle === 'second-block')!
const buildForBundle = late.buildForBundle.bind(late)
late.buildForBundle = async (options, bundleDirectory) => {
await new Promise((resolve) => setTimeout(resolve, SKEW_MS))
return buildForBundle(options, bundleDirectory)
}
}
try {
// what `shopify app deploy` runs before it uploads
// eslint-disable-next-line no-await-in-loop
await bundleAndBuildExtensions({
app,
appManifest: await app.manifest(undefined),
bundlePath: join(directory, '.shopify', 'deploy-bundle.zip'),
skipBuild: false,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
failures.push(String(error?.message ?? error).replace(directory, '<app>'))
// the real CLI exits here; let the other extension's build settle before the next round
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 3000))
}
rmSync(directory, {recursive: true, force: true})
}
expect(failures, `${failures.length} of ${iterations} deploys failed`).toEqual([])
},
3_600_000,
)
Possible fixes
Each of these makes the test pass 10 of 10 with the delays on:
- In
executeBundleUIStep, leave*.js.mapout of the folder copy. The build now runs in the extension's own folder, so the map is already wherekeepBuiltSourcemapsLocallyputs it, and the upload excludes maps anyway (BUNDLE_EXCLUSION_PATTERNS). - In
keepBuiltSourcemapsLocally, a plain rename (renameFile) instead ofmoveFile(..., {overwrite: true}). A rename replaces the destination in one step, so the map never goes missing. It loses fs-extra's copy fallback for the case where the bundle folder and the extension folder sit on different filesystems.
#8236 (closed) would have removed it too, by copying only the extension's own outputs.
Workaround
In CI: shopify app build, delete extensions/*/dist/*.js.map, then shopify app deploy --no-build. With no rebuild and no map to move back, nothing writes into dist/ during the deploy. --no-build alone is not enough, because the maps are still moved back.
I am filing this here rather than on the dev community because it comes with a failing test against this repository. Tell me if you would rather have it there.
- Lingua principale
- TypeScript
- Stelle
- 753
- Fork
- 295
- Merge medio
- 2g 2h
- PR unite (30g)
- 138
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di Shopify/cli
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
Shopify/cli#8753 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: `theme dev` proxy 502s on cookie-heavy storefront responses (undici default 16 KiB `maxHeaderSize`)Forse già presa @efegokdemir l’ha presa 6 giorni fa. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 68/100
Shopify/cli#8717 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: `shopify hydrogen dev` exits before listening when the npm registry is unreachable (p-cancelable onCancel-after-settle in latest-version chunk)Forse già presa @efegokdemir l’ha presa 5 giorni fa. Aperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
I maintainer di solito rispondono entro 1 giorno
-
no-issue-activity
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
Shopify/cli#8388 · 4 commenti ·
I maintainer di solito rispondono entro 1 giorno
Issue simili
-
bug(sight): the dashboard's text truncations split surrogate pairs and show broken charactersApertacomponent:sight
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
agentic-os-org/ANOLISA#6738 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
bug Durable Agents Observability (AI Telemetry) status: needs triage
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
mastra-ai/mastra#26470 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
paperclipai/paperclip#15630 ·
I maintainer di solito rispondono entro 1 giorno
-
[good first issue, hacktoberfest] ⛩️ Add new Theme: Sakura Latte (good-first-issue)Forse già presa @PGrayCS l’ha presa oggi. Apertacommunity first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficoltà 1/5 1-3 ore Idoneità per principianti 78/100
lingdojo/kana-dojo#31937 · 1 commento · 5 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
feature/cohorts feature/feature-flags team/feature-flags
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
I maintainer di solito rispondono entro 1 giorno