Skip to content

Commit 5b3b59c

Browse files
committed
Use atomicFS for every fs call in theme
atomicFS is moved into fileSystemHelpers, next to the atomicReplace.
1 parent 19d534d commit 5b3b59c

File tree

4 files changed

+101
-105
lines changed

4 files changed

+101
-105
lines changed

src/e2e-tests/helpers.js

Lines changed: 0 additions & 68 deletions
This file was deleted.

src/e2e-tests/theme.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const { tmpdir } = require('os')
22
const { join } = require('path')
33
const test = require('tape')
44
const writ = require('..')
5-
const { atomicFS } = require('./helpers')
5+
const { atomicFS } = require('../lib/fileSystemHelpers')
66

77
test('Theme', t => {
88

@@ -483,4 +483,4 @@ test('Theme', t => {
483483
}
484484
})
485485

486-
})
486+
})

src/lib/fileSystemHelpers.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const fs = require('fs/promises')
22
const { join } = require('path')
3-
const { tmpdir } = require('os')
3+
const { tmpdir, platform } = require('os')
44

55
const readFileContent = path => {
66
return fs.readFile(path, { encoding: 'utf-8' })
@@ -52,10 +52,81 @@ const atomicReplace = async (targetPath, buildFn) => {
5252
}
5353
}
5454

55+
/*
56+
* Retry an operation when Windows file handles prevent immediate access.
57+
* On Windows, fs operations may fail with EBUSY if file handles aren't
58+
* released yet. This retries the operation with backoff until it succeeds,
59+
* mimicking the atomic pattern of building in isolation before swap.
60+
*/
61+
const atomicFS = (() => {
62+
const sleep = (duration) => new Promise(resolve => setTimeout(resolve, duration))
63+
64+
const retryUntilSuccess = async (fn, maxRetries = 10, delayMS = 50) => {
65+
if (platform() !== 'win32') {
66+
return await fn()
67+
}
68+
69+
for (let i = 0; i < maxRetries; i++) {
70+
try {
71+
return await fn()
72+
} catch (error) {
73+
if (error.code === 'EBUSY' && i < maxRetries - 1) {
74+
console.log(`
75+
* * * *
76+
Caught EBUSY and retrying (${i}/${maxRetries})
77+
78+
function:
79+
`)
80+
console.log(fn.toString())
81+
console.log(`
82+
* * * *
83+
`)
84+
await sleep(delayMS)
85+
continue
86+
}
87+
throw error
88+
}
89+
}
90+
}
91+
92+
return {
93+
readdir: (path) => retryUntilSuccess(() => fs.readdir(path)),
94+
95+
readdirRecursive: (path) => retryUntilSuccess(
96+
() => fs.readdir(path, { recursive: true, withFileTypes: true })
97+
),
98+
99+
readFile: (path, encoding = 'utf-8') => retryUntilSuccess(
100+
() => fs.readFile(path, encoding)
101+
),
102+
103+
rm: (path, options = { recursive: true, force: true }) => retryUntilSuccess(
104+
() => fs.rm(path, options)
105+
),
106+
107+
mkdir: (path, options = { recursive: true }) => retryUntilSuccess(
108+
() => fs.mkdir(path, options)
109+
),
110+
111+
writeFile: (path, data, options = 'utf-8') => retryUntilSuccess(
112+
() => fs.writeFile(path, data, options)
113+
),
114+
115+
stat: (path) => retryUntilSuccess(() => fs.stat(path)),
116+
117+
cp: (src, dest, options = { recursive: true }) => retryUntilSuccess(
118+
() => fs.cp(src, dest, options)
119+
),
120+
121+
mkdtemp: (prefix) => retryUntilSuccess(() => fs.mkdtemp(prefix))
122+
}
123+
})()
124+
55125
module.exports = {
56126
readFileContent,
57127
loadJSON,
58128
isDirectory,
59129
ensureDirectory,
60-
atomicReplace
130+
atomicReplace,
131+
atomicFS
61132
}

src/theme/index.js

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
const { tmpdir } = require('os')
2-
const { stat, rm, mkdir, mkdtemp, cp, readdir } = require('fs/promises')
32
const { join } = require('path')
43
const Debug = require('../debug')
54
const Settings = require('../settings')
65
const createDecorator = require('./decorator')
7-
const { atomicReplace } = require('../lib/fileSystemHelpers')
6+
const { atomicReplace, atomicFS } = require('../lib/fileSystemHelpers')
87
const {
98
ASSETS,
109
PARTIALS,
@@ -40,7 +39,7 @@ const Methods = (() => {
4039

4140
const customThemeExists = async (customThemePath) => {
4241
try {
43-
return await stat(customThemePath)
42+
return await atomicFS.stat(customThemePath)
4443
} catch {
4544
return false
4645
}
@@ -49,10 +48,12 @@ const Methods = (() => {
4948
const refreshCustomTheme = async (customThemePath) => {
5049
const backupKeepDir = async (keepPath) => {
5150
try {
52-
if (await stat(keepPath)) {
53-
const tempPath = await mkdtemp(join(tmpdir(), 'writ-theme-keep'))
51+
if (await atomicFS.stat(keepPath)) {
52+
const tempPath = await atomicFS.mkdtemp(
53+
join(tmpdir(), 'writ-theme-keep')
54+
)
5455
Debug.debugLog('refresh theme temp dir', tempPath)
55-
await cp(keepPath, tempPath, { recursive: true })
56+
await atomicFS.cp(keepPath, tempPath)
5657
return tempPath
5758
}
5859
} catch {
@@ -62,10 +63,7 @@ const Methods = (() => {
6263
}
6364

6465
const applyKeepOverrides = async (keepBackupPath, themePath) => {
65-
const entries = await readdir(keepBackupPath, {
66-
recursive: true,
67-
withFileTypes: true
68-
})
66+
const entries = await atomicFS.readdirRecursive(keepBackupPath)
6967

7068
await Promise.all(
7169
entries
@@ -75,7 +73,7 @@ const Methods = (() => {
7573
entry.parentPath.replace(keepBackupPath, ''),
7674
entry.name
7775
)
78-
return cp(
76+
return atomicFS.cp(
7977
join(keepBackupPath, relativePath),
8078
join(themePath, relativePath)
8179
)
@@ -96,10 +94,8 @@ const Methods = (() => {
9694
})
9795

9896
if (keepBackupPath) {
99-
await mkdir(join(tempPath, KEEP_PATH), { recursive: true })
100-
await cp(keepBackupPath, join(tempPath, KEEP_PATH), {
101-
recursive: true
102-
})
97+
await atomicFS.mkdir(join(tempPath, KEEP_PATH))
98+
await atomicFS.cp(keepBackupPath, join(tempPath, KEEP_PATH))
10399
await applyKeepOverrides(keepBackupPath, tempPath)
104100
}
105101
})
@@ -108,13 +104,13 @@ const Methods = (() => {
108104
throw e
109105
} finally {
110106
if (keepBackupPath) {
111-
await rm(keepBackupPath, { recursive: true, force: true })
107+
await atomicFS.rm(keepBackupPath)
112108
}
113109
}
114110
}
115111

116112
const collectCustomizerPaths = async (customThemePath) => {
117-
const paths = await readdir(customThemePath)
113+
const paths = await atomicFS.readdir(customThemePath)
118114
const customizerPaths = paths.filter(p => {
119115
return p.endsWith('.css') || p.endsWith('.js')
120116
})
@@ -123,12 +119,11 @@ const Methods = (() => {
123119

124120
const copyCommonResources = (targetPath) => {
125121
return Promise.all([
126-
cp(
122+
atomicFS.cp(
127123
join(__dirname, 'common', PARTIALS.from),
128-
join(targetPath, PARTIALS.to),
129-
{ recursive: true }
124+
join(targetPath, PARTIALS.to)
130125
),
131-
cp(
126+
atomicFS.cp(
132127
join(__dirname, 'common', TEMPLATE_HELPERS.from),
133128
join(targetPath, PARTIALS.to, TEMPLATE_HELPERS.to)
134129
)
@@ -139,17 +134,15 @@ const Methods = (() => {
139134
const { theme } = Settings.getSettings()
140135
const themeSrcPath = join(__dirname, '..', '..', 'packages', `theme-${theme}`)
141136
return Promise.all([
142-
cp(
137+
atomicFS.cp(
143138
join(themeSrcPath, ASSETS),
144-
join(customThemePath, ASSETS, theme),
145-
{ recursive: true }
139+
join(customThemePath, ASSETS, theme)
146140
),
147-
cp(
141+
atomicFS.cp(
148142
join(themeSrcPath, PARTIALS.from),
149-
join(customThemePath, PARTIALS.to),
150-
{ recursive: true }
143+
join(customThemePath, PARTIALS.to)
151144
),
152-
cp(
145+
atomicFS.cp(
153146
join(themeSrcPath, THEME_SETTINGS),
154147
join(customThemePath, THEME_SETTINGS)
155148
)
@@ -161,14 +154,14 @@ const Methods = (() => {
161154
}
162155

163156
const copyCustomizers = async (customThemePath) => {
164-
const paths = await readdir(join(__dirname, 'customizers'))
157+
const paths = await atomicFS.readdir(join(__dirname, 'customizers'))
165158
const customizers = paths.filter(p => {
166159
return p.endsWith('.css') || p.endsWith('.js')
167160
})
168161
State.customizers.push(...customizers)
169162
return Promise.all(
170163
paths.map(path => {
171-
return cp(
164+
return atomicFS.cp(
172165
join(__dirname, 'customizers', path),
173166
join(customThemePath, path)
174167
)
@@ -178,12 +171,12 @@ const Methods = (() => {
178171

179172
const makeCustomThemeDirectory = async (customThemePath, options = {}) => {
180173
if (!options.skipContainer) {
181-
await mkdir(customThemePath)
174+
await atomicFS.mkdir(customThemePath)
182175
}
183176

184177
await Promise.all([
185-
mkdir(join(customThemePath, ASSETS)),
186-
mkdir(join(customThemePath, PARTIALS.to))
178+
atomicFS.mkdir(join(customThemePath, ASSETS)),
179+
atomicFS.mkdir(join(customThemePath, PARTIALS.to))
187180
])
188181

189182
await copyCommonResources(customThemePath)

0 commit comments

Comments
 (0)