Skip to content

Commit edd1171

Browse files
committed
chore: bump version
1 parent 6b2055f commit edd1171

File tree

3 files changed

+131
-8
lines changed

3 files changed

+131
-8
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'tailwindcss-patch': patch
3+
---
4+
5+
Gracefully skip cache updates when the cache file is locked on Windows to avoid EPERM failures.

packages/tailwindcss-patch/src/cache/store.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
77
return error instanceof Error && typeof (error as NodeJS.ErrnoException).code === 'string'
88
}
99

10+
function isAccessDenied(error: unknown): error is NodeJS.ErrnoException {
11+
return isErrnoException(error)
12+
&& Boolean(error.code && ['EPERM', 'EBUSY', 'EACCES'].includes(error.code))
13+
}
14+
1015
export class CacheStore {
1116
constructor(private readonly options: NormalizedCacheOptions) {}
1217

@@ -23,46 +28,58 @@ export class CacheStore {
2328
return `${this.options.path}.${uniqueSuffix}.tmp`
2429
}
2530

26-
private async replaceCacheFile(tempPath: string) {
31+
private async replaceCacheFile(tempPath: string): Promise<boolean> {
2732
try {
2833
await fs.rename(tempPath, this.options.path)
34+
return true
2935
}
3036
catch (error) {
3137
if (isErrnoException(error) && (error.code === 'EEXIST' || error.code === 'EPERM')) {
3238
try {
3339
await fs.remove(this.options.path)
3440
}
3541
catch (removeError) {
42+
if (isAccessDenied(removeError)) {
43+
logger.debug('Tailwind class cache locked or read-only, skipping update.', removeError)
44+
return false
45+
}
46+
3647
if (!isErrnoException(removeError) || removeError.code !== 'ENOENT') {
3748
throw removeError
3849
}
3950
}
4051

4152
await fs.rename(tempPath, this.options.path)
42-
return
53+
return true
4354
}
4455

4556
throw error
4657
}
4758
}
4859

49-
private replaceCacheFileSync(tempPath: string) {
60+
private replaceCacheFileSync(tempPath: string): boolean {
5061
try {
5162
fs.renameSync(tempPath, this.options.path)
63+
return true
5264
}
5365
catch (error) {
5466
if (isErrnoException(error) && (error.code === 'EEXIST' || error.code === 'EPERM')) {
5567
try {
5668
fs.removeSync(this.options.path)
5769
}
5870
catch (removeError) {
71+
if (isAccessDenied(removeError)) {
72+
logger.debug('Tailwind class cache locked or read-only, skipping update.', removeError)
73+
return false
74+
}
75+
5976
if (!isErrnoException(removeError) || removeError.code !== 'ENOENT') {
6077
throw removeError
6178
}
6279
}
6380

6481
fs.renameSync(tempPath, this.options.path)
65-
return
82+
return true
6683
}
6784

6885
throw error
@@ -94,8 +111,13 @@ export class CacheStore {
94111
await this.ensureDir()
95112
// Persist to a temp file first so concurrent writers never expose partial JSON
96113
await fs.writeJSON(tempPath, Array.from(data))
97-
await this.replaceCacheFile(tempPath)
98-
return this.options.path
114+
const replaced = await this.replaceCacheFile(tempPath)
115+
if (replaced) {
116+
return this.options.path
117+
}
118+
119+
await this.cleanupTempFile(tempPath)
120+
return undefined
99121
}
100122
catch (error) {
101123
await this.cleanupTempFile(tempPath)
@@ -114,8 +136,13 @@ export class CacheStore {
114136
try {
115137
this.ensureDirSync()
116138
fs.writeJSONSync(tempPath, Array.from(data))
117-
this.replaceCacheFileSync(tempPath)
118-
return this.options.path
139+
const replaced = this.replaceCacheFileSync(tempPath)
140+
if (replaced) {
141+
return this.options.path
142+
}
143+
144+
this.cleanupTempFileSync(tempPath)
145+
return undefined
119146
}
120147
catch (error) {
121148
this.cleanupTempFileSync(tempPath)

packages/tailwindcss-patch/test/cache.store.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,95 @@ describe('CacheStore', () => {
202202
pathExistsSpy.mockRestore()
203203
readSpy.mockRestore()
204204
})
205+
206+
it('skips cache writes when the destination cannot be removed (async)', async () => {
207+
const cachePath = path.join(tempDir, 'cache.json')
208+
await fs.writeJSON(cachePath, ['old'])
209+
210+
const store = new CacheStore({
211+
enabled: true,
212+
cwd: tempDir,
213+
dir: tempDir,
214+
file: 'cache.json',
215+
path: cachePath,
216+
strategy: 'merge',
217+
})
218+
219+
const writeJSONSpy = vi.spyOn(fs, 'writeJSON')
220+
const debugSpy = vi.spyOn(logger, 'debug')
221+
const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(
222+
Object.assign(new Error('exists'), { code: 'EEXIST' as NodeJS.ErrnoException['code'] }),
223+
)
224+
const realRemove = fs.remove
225+
let removeAttempts = 0
226+
const removeSpy = vi.spyOn(fs, 'remove').mockImplementation(async (...args) => {
227+
removeAttempts += 1
228+
if (removeAttempts === 1) {
229+
const error = Object.assign(new Error('locked'), { code: 'EPERM' as NodeJS.ErrnoException['code'] })
230+
throw error
231+
}
232+
return realRemove.apply(fs, args as Parameters<typeof fs.remove>)
233+
})
234+
235+
const result = await store.write(new Set(['fresh']))
236+
expect(result).toBeUndefined()
237+
expect(renameSpy).toHaveBeenCalledTimes(1)
238+
expect(removeSpy).toHaveBeenCalledTimes(2)
239+
expect(debugSpy).toHaveBeenCalled()
240+
241+
const tempPath = writeJSONSpy.mock.calls[0]?.[0] as string
242+
expect(tempPath).toBeTruthy()
243+
expect(await fs.pathExists(tempPath)).toBe(false)
244+
245+
writeJSONSpy.mockRestore()
246+
debugSpy.mockRestore()
247+
renameSpy.mockRestore()
248+
removeSpy.mockRestore()
249+
})
250+
251+
it('skips cache writes when the destination cannot be removed (sync)', () => {
252+
const cachePath = path.join(tempDir, 'cache.json')
253+
fs.writeJSONSync(cachePath, ['old'])
254+
255+
const store = new CacheStore({
256+
enabled: true,
257+
cwd: tempDir,
258+
dir: tempDir,
259+
file: 'cache.json',
260+
path: cachePath,
261+
strategy: 'merge',
262+
})
263+
264+
const writeJSONSpy = vi.spyOn(fs, 'writeJSONSync')
265+
const debugSpy = vi.spyOn(logger, 'debug')
266+
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
267+
const error = Object.assign(new Error('exists'), { code: 'EEXIST' as NodeJS.ErrnoException['code'] })
268+
throw error
269+
})
270+
const realRemove = fs.removeSync
271+
let removeAttempts = 0
272+
const removeSpy = vi.spyOn(fs, 'removeSync').mockImplementation((...args) => {
273+
removeAttempts += 1
274+
if (removeAttempts === 1) {
275+
const error = Object.assign(new Error('locked'), { code: 'EPERM' as NodeJS.ErrnoException['code'] })
276+
throw error
277+
}
278+
return realRemove.apply(fs, args as Parameters<typeof fs.removeSync>)
279+
})
280+
281+
const result = store.writeSync(new Set(['fresh']))
282+
expect(result).toBeUndefined()
283+
expect(renameSpy).toHaveBeenCalledTimes(1)
284+
expect(removeSpy).toHaveBeenCalledTimes(2)
285+
expect(debugSpy).toHaveBeenCalled()
286+
287+
const tempPath = writeJSONSpy.mock.calls[0]?.[0] as string
288+
expect(tempPath).toBeTruthy()
289+
expect(fs.pathExistsSync(tempPath)).toBe(false)
290+
291+
writeJSONSpy.mockRestore()
292+
debugSpy.mockRestore()
293+
renameSpy.mockRestore()
294+
removeSpy.mockRestore()
295+
})
205296
})

0 commit comments

Comments
 (0)