Skip to content

Commit bb52e42

Browse files
Avoid problems with bundle writing while compressing the bundle
1 parent 1b47f9a commit bb52e42

File tree

2 files changed

+124
-15
lines changed

2 files changed

+124
-15
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: 36 additions & 13 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,22 @@ export async function zip(options: ZipOptions): Promise<void> {
6464
archive.append(Buffer.alloc(0), {name: dirName})
6565
}
6666

67-
for (const filePath of pathsToZip) {
68-
const fileRelativePath = relativePath(inputDirectory, filePath)
69-
if (filePath && fileRelativePath) archive.file(filePath, {name: fileRelativePath})
70-
}
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) => {
70+
await archiveFile(inputDirectory, filePath, archive)
71+
})
7172

72-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
73-
archive.finalize()
73+
// Wait for all files to be read and added before finalizing
74+
Promise.all(addFilesPromises)
75+
.then(() => {
76+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
77+
archive.finalize()
78+
})
79+
.catch((error) => {
80+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
81+
reject(error)
82+
})
7483
})
7584
}
7685

@@ -84,6 +93,16 @@ function collectParentDirectories(fileRelativePath: string, accumulator: Set<str
8493
}
8594
}
8695

96+
async function archiveFile(inputDirectory: string, filePath: string, archive: archiver.Archiver): Promise<void> {
97+
const fileRelativePath = relativePath(inputDirectory, filePath)
98+
if (!filePath || !fileRelativePath) return
99+
100+
// Read file content immediately to avoid race conditions
101+
const fileContent = await readFile(filePath)
102+
// Use append with Buffer instead of file() to avoid lazy file reading
103+
archive.append(fileContent, {name: fileRelativePath})
104+
}
105+
87106
export interface BrotliOptions {
88107
/**
89108
* The directory to compress.
@@ -147,15 +166,19 @@ export async function brotliCompress(options: BrotliOptions): Promise<void> {
147166
dot: true,
148167
followSymbolicLinks: false,
149168
})
150-
.then((pathsToZip) => {
151-
for (const filePath of pathsToZip) {
152-
const fileRelativePath = relativePath(options.inputDirectory, filePath)
153-
archive.file(filePath, {name: fileRelativePath})
154-
}
169+
.then(async (pathsToZip) => {
170+
// Read all files immediately to prevent ENOENT errors during race conditions
171+
const addFilesPromises = pathsToZip.map(async (filePath) => {
172+
await archiveFile(options.inputDirectory, filePath, archive)
173+
})
174+
175+
await Promise.all(addFilesPromises)
155176
// eslint-disable-next-line @typescript-eslint/no-floating-promises
156177
archive.finalize()
157178
})
158-
.catch((error) => reject(error instanceof Error ? error : new Error(String(error))))
179+
.catch((error) => {
180+
reject(error instanceof Error ? error : new Error(String(error)))
181+
})
159182
})
160183

161184
const tarContent = readFileSync(tempTarPath)

0 commit comments

Comments
 (0)