Skip to content

Commit a515640

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

File tree

2 files changed

+150
-12
lines changed

2 files changed

+150
-12
lines changed

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

Lines changed: 88 additions & 2 deletions
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, options?: unknown) => {
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, options as never)
85+
}) as typeof originalReadFile)
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', () => {
@@ -66,7 +111,6 @@ describe('brotliCompress', () => {
66111
const brotliPath = joinPath(tmpDir, 'output.br')
67112
const outputDirectoryName = 'output'
68113
const outputDirectoryPath = joinPath(tmpDir, outputDirectoryName)
69-
const extractPath = joinPath(tmpDir, 'extract')
70114
const testContent = 'test content'
71115

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

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

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

Lines changed: 62 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,43 @@ 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+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
102+
reject(error)
103+
})
74104
})
75105
}
76106

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

0 commit comments

Comments
 (0)