[Bug]: app deploy fails intermittently with ENOENT on <handle>.js.map when two UI extensions share a folder
Maintainer thường phản hồi trong vòng 2 ngày
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 67/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- node.js, typescript
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- TypeScript
- Star
- 753
- Fork
- 295
- Merge trung bình
- 2 ngày 8 giờ
- Pull request đã merge (30 ngày)
- 141
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của Shopify/cli
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
Shopify/cli#8753 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 2 ngày
-
[Bug]: `theme dev` proxy 502s on cookie-heavy storefront responses (undici default 16 KiB `maxHeaderSize`)Có thể đã có người làm @efegokdemir đã nhận 6 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 68/100
Shopify/cli#8717 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 2 ngày
-
[Bug]: `shopify hydrogen dev` exits before listening when the npm registry is unreachable (p-cancelable onCancel-after-settle in latest-version chunk)Có thể đã có người làm @efegokdemir đã nhận 6 ngày trước. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
Maintainer thường phản hồi trong vòng 2 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
Maintainer thường phản hồi trong vòng 2 ngày
-
no-issue-activity
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
Shopify/cli#8388 · 4 bình luận ·
Maintainer thường phản hồi trong vòng 2 ngày
Issue tương tự
-
[Feature]: [P3] engine-rs: the package source hash should ignore line endings and untracked filesĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
maniator/verticopolis#880 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
siyuan-note/siyuan#20353 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
black-forest-labs/skills#17 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
Albert-Weasker/niubigeo#168 ·
Maintainer thường phản hồi trong vòng 1 ngày