[Bug]: app deploy fails intermittently with ENOENT on <handle>.js.map when two UI extensions share a folder
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 67/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- node.js, typescript
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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.
- 主要言語
- TypeScript
- スター
- 753
- フォーク
- 295
- 平均マージ
- 2日 2時間
- マージ済み PR(30日)
- 138
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
Shopify/cli のほかの issue
-
難易度 4/5 3〜5日 初心者へのやさしさ 25/100
メンテナーはふだん 1 日以内に返信
-
[Bug]: `theme dev` proxy 502s on cookie-heavy storefront responses (undici default 16 KiB `maxHeaderSize`)対応中かも @efegokdemir が 6 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
-
[Bug]: `shopify hydrogen dev` exits before listening when the npm registry is unreachable (p-cancelable onCancel-after-settle in latest-version chunk)対応中かも @efegokdemir が 5 日前に担当しました。 オープン
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
メンテナーはふだん 1 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
メンテナーはふだん 1 日以内に返信
-
no-issue-activity
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
メンテナーはふだん 1 日以内に返信
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1〜3時間 初心者へのやさしさ 84/100
answerLoops/answerLoops#345 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 82/100
siyuan-note/siyuan#20313 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
LanternOps/breeze#8254 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1〜3時間 初心者へのやさしさ 82/100
gofish-graphics/gofish-graphics#1084 ·
メンテナーはふだん 1 日以内に返信