Skip to content

Commit 53e1e4f

Browse files
Avoid problems with bundle writing while compressing the bundle
1 parent 0810f84 commit 53e1e4f

File tree

2 files changed

+149
-11
lines changed

2 files changed

+149
-11
lines changed

packages/cli-kit/src/public/node/archiver.integration.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {zip, brotliCompress} from './archiver.js'
22
import {fileExists, inTemporaryDirectory, mkdir, touchFile} from './fs.js'
33
import {joinPath, dirname} from './path.js'
44
import {exec} from './system.js'
5-
import {describe, expect, test} from 'vitest'
5+
import {describe, expect, test, vi} from 'vitest'
66
import StreamZip from 'node-stream-zip'
77
import brotli from 'brotli'
88
import fs from 'fs'
@@ -57,6 +57,51 @@ describe('zip', () => {
5757
expect(expectedEntries.sort()).toEqual(archiveEntries.sort())
5858
})
5959
})
60+
61+
test('gracefully handles files deleted during archiving', async () => {
62+
await inTemporaryDirectory(async (tmpDir) => {
63+
// Given
64+
const zipPath = joinPath(tmpDir, 'output.zip')
65+
const outputDirectoryName = 'output'
66+
const outputDirectoryPath = joinPath(tmpDir, outputDirectoryName)
67+
const structure = ['file1.js', 'file2.js', 'file3.js']
68+
69+
await createFiles(structure, outputDirectoryPath)
70+
71+
const file2Path = joinPath(outputDirectoryPath, 'file2.js')
72+
73+
// Spy on readFile to simulate a file being deleted during archiving
74+
// We'll make readFile throw ENOENT for file2.js specifically
75+
const fsModule = await import('./fs.js')
76+
const originalReadFile = fsModule.readFile
77+
const readFileSpy = vi.spyOn(fsModule, 'readFile')
78+
readFileSpy.mockImplementation(async (path: string) => {
79+
if (path === file2Path) {
80+
const error: NodeJS.ErrnoException = new Error(`ENOENT: no such file or directory, open '${path}'`)
81+
error.code = 'ENOENT'
82+
throw error
83+
}
84+
return originalReadFile(path)
85+
})
86+
87+
// When - should not throw even though file2.js throws ENOENT
88+
await expect(
89+
zip({
90+
inputDirectory: outputDirectoryPath,
91+
outputZipPath: zipPath,
92+
}),
93+
).resolves.not.toThrow()
94+
95+
// Then - archive should contain remaining files only
96+
const archiveEntries = await readArchiveFiles(zipPath)
97+
expect(archiveEntries).toContain('file1.js')
98+
expect(archiveEntries).toContain('file3.js')
99+
// file2.js should not be in archive since readFile failed
100+
expect(archiveEntries).not.toContain('file2.js')
101+
102+
readFileSpy.mockRestore()
103+
})
104+
})
60105
})
61106

62107
describe('brotliCompress', () => {
@@ -141,6 +186,48 @@ describe('brotliCompress', () => {
141186
expect(fs.existsSync(joinPath(extractPath, 'test.json'))).toBeFalsy()
142187
})
143188
})
189+
190+
test('gracefully handles files deleted during compression', async () => {
191+
await inTemporaryDirectory(async (tmpDir) => {
192+
// Given
193+
const brotliPath = joinPath(tmpDir, 'output.br')
194+
const outputDirectoryName = 'output'
195+
const outputDirectoryPath = joinPath(tmpDir, outputDirectoryName)
196+
const structure = ['file1.js', 'file2.js', 'file3.js']
197+
198+
await createFiles(structure, outputDirectoryPath)
199+
200+
const file2Path = joinPath(outputDirectoryPath, 'file2.js')
201+
202+
// Spy on readFile to simulate a file being deleted during compression
203+
// We'll make readFile throw ENOENT for file2.js specifically
204+
const fsModule = await import('./fs.js')
205+
const originalReadFile = fsModule.readFile
206+
const readFileSpy = vi.spyOn(fsModule, 'readFile')
207+
readFileSpy.mockImplementation(async (path: string) => {
208+
if (path === file2Path) {
209+
const error: NodeJS.ErrnoException = new Error(`ENOENT: no such file or directory, open '${path}'`)
210+
error.code = 'ENOENT'
211+
throw error
212+
}
213+
return originalReadFile(path)
214+
})
215+
216+
// When - should not throw even though file2.js throws ENOENT
217+
await expect(
218+
brotliCompress({
219+
inputDirectory: outputDirectoryPath,
220+
outputPath: brotliPath,
221+
}),
222+
).resolves.not.toThrow()
223+
224+
// Then - compressed file should exist and be valid
225+
const exists = await fileExists(brotliPath)
226+
expect(exists).toBeTruthy()
227+
228+
readFileSpy.mockRestore()
229+
})
230+
})
144231
})
145232

146233
async function createFiles(structure: string[], directory: string) {

packages/cli-kit/src/public/node/archiver.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {relativePath, joinPath, dirname} from './path.js'
2-
import {glob, removeFile} from './fs.js'
2+
import {glob, removeFile, readFile} from './fs.js'
33
import {outputDebug, outputContent, outputToken} from '../../public/node/output.js'
44
import archiver from 'archiver'
55
import {createWriteStream, readFileSync, writeFileSync} from 'fs'
@@ -64,13 +64,42 @@ export async function zip(options: ZipOptions): Promise<void> {
6464
archive.append(Buffer.alloc(0), {name: dirName})
6565
}
6666

67-
for (const filePath of pathsToZip) {
67+
// Read all files immediately before adding to archive to prevent ENOENT errors
68+
// Using archive.file() causes lazy loading which fails if files are deleted before finalize()
69+
const addFilesPromises = pathsToZip.map(async (filePath) => {
6870
const fileRelativePath = relativePath(inputDirectory, filePath)
69-
if (filePath && fileRelativePath) archive.file(filePath, {name: fileRelativePath})
70-
}
71+
if (!filePath || !fileRelativePath) return
72+
73+
try {
74+
// Read file content immediately to avoid race conditions
75+
const fileContent = await readFile(filePath)
76+
// Use append with Buffer instead of file() to avoid lazy file reading
77+
archive.append(fileContent, {name: fileRelativePath})
78+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-catch-all/no-catch-all
79+
} catch (error: any) {
80+
// If file was deleted during bundling, skip it gracefully
81+
const errorMessage = error instanceof Error ? error.message : String(error)
82+
if (errorMessage.includes('ENOENT')) {
83+
outputDebug(outputContent`File was deleted during bundling, skipping: ${outputToken.path(fileRelativePath)}`)
84+
} else {
85+
outputDebug(
86+
outputContent`Failed to read file for archive, skipping: ${outputToken.path(
87+
fileRelativePath,
88+
)} - ${outputToken.raw(errorMessage)}`,
89+
)
90+
}
91+
}
92+
})
7193

72-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
73-
archive.finalize()
94+
// Wait for all files to be read and added before finalizing
95+
Promise.all(addFilesPromises)
96+
.then(() => {
97+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
98+
archive.finalize()
99+
})
100+
.catch((error) => {
101+
reject(error)
102+
})
74103
})
75104
}
76105

@@ -147,11 +176,33 @@ export async function brotliCompress(options: BrotliOptions): Promise<void> {
147176
dot: true,
148177
followSymbolicLinks: false,
149178
})
150-
.then((pathsToZip) => {
151-
for (const filePath of pathsToZip) {
179+
.then(async (pathsToZip) => {
180+
// Read all files immediately to prevent ENOENT errors during race conditions
181+
const addFilesPromises = pathsToZip.map(async (filePath) => {
152182
const fileRelativePath = relativePath(options.inputDirectory, filePath)
153-
archive.file(filePath, {name: fileRelativePath})
154-
}
183+
try {
184+
// Read file content immediately to avoid race conditions
185+
const fileContent = await readFile(filePath)
186+
// Use append with Buffer instead of file() to avoid lazy file reading
187+
archive.append(fileContent, {name: fileRelativePath})
188+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-catch-all/no-catch-all
189+
} catch (error: any) {
190+
const errorMessage = error instanceof Error ? error.message : String(error)
191+
if (errorMessage.includes('ENOENT')) {
192+
outputDebug(
193+
outputContent`File was deleted during bundling, skipping: ${outputToken.path(fileRelativePath)}`,
194+
)
195+
} else {
196+
outputDebug(
197+
outputContent`Failed to read file for tar archive, skipping: ${outputToken.path(
198+
fileRelativePath,
199+
)} - ${outputToken.raw(errorMessage)}`,
200+
)
201+
}
202+
}
203+
})
204+
205+
await Promise.all(addFilesPromises)
155206
// eslint-disable-next-line @typescript-eslint/no-floating-promises
156207
archive.finalize()
157208
})

0 commit comments

Comments
 (0)