vxrn 2.8.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/exports/clean.mjs +128 -19
- package/dist/exports/clean.mjs.map +1 -1
- package/dist/exports/clean.native.js +147 -21
- package/dist/exports/clean.native.js.map +1 -1
- package/dist/exports/prebuild.mjs +2 -1
- package/dist/exports/prebuild.mjs.map +1 -1
- package/dist/exports/prebuild.native.js +2 -1
- package/dist/exports/prebuild.native.js.map +1 -1
- package/dist/exports/prebuildWithoutExpo.mjs +196 -7
- package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
- package/dist/exports/prebuildWithoutExpo.native.js +280 -42
- package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
- package/dist/plugins/reactNativeDevServer.mjs +1 -1
- package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
- package/dist/plugins/reactNativeDevServer.native.js +1 -1
- package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
- package/dist/user-interface/index.mjs +31 -24
- package/dist/user-interface/index.mjs.map +1 -1
- package/dist/user-interface/index.native.js +32 -25
- package/dist/user-interface/index.native.js.map +1 -1
- package/dist/utils/createNativeDevEngine.mjs +111 -14
- package/dist/utils/createNativeDevEngine.mjs.map +1 -1
- package/dist/utils/createNativeDevEngine.native.js +143 -14
- package/dist/utils/createNativeDevEngine.native.js.map +1 -1
- package/dist/utils/scanDepsToOptimize.mjs +10 -6
- package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
- package/dist/utils/scanDepsToOptimize.native.js +12 -6
- package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
- package/expo-plugin.cjs +71 -8
- package/package.json +13 -13
- package/src/exports/clean.test.ts +502 -5
- package/src/exports/clean.ts +176 -19
- package/src/exports/expoPlugin.test.mjs +44 -1
- package/src/exports/prebuild.ts +6 -0
- package/src/exports/prebuildWithoutExpo.test.ts +134 -2
- package/src/exports/prebuildWithoutExpo.ts +235 -10
- package/src/plugins/reactNativeDevServer.ts +1 -1
- package/src/user-interface/index.ts +50 -42
- package/src/utils/createNativeDevEngine.test.ts +480 -1
- package/src/utils/createNativeDevEngine.ts +220 -17
- package/src/utils/imageSizePatch.test.ts +3 -3
- package/src/utils/scanDepsToOptimize.test.ts +35 -2
- package/src/utils/scanDepsToOptimize.ts +25 -8
- package/types/exports/clean.d.ts.map +1 -1
- package/types/exports/prebuild.d.ts +2 -1
- package/types/exports/prebuild.d.ts.map +1 -1
- package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
- package/types/user-interface/index.d.ts.map +1 -1
- package/types/utils/createNativeDevEngine.d.ts +1 -1
- package/types/utils/createNativeDevEngine.d.ts.map +1 -1
- package/types/utils/scanDepsToOptimize.d.ts +25 -0
- package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
2
|
-
import {
|
|
1
|
+
import { execFile, spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { connect, createServer, type Server, type Socket } from 'node:net'
|
|
3
6
|
import { tmpdir } from 'node:os'
|
|
4
7
|
import { dirname, join } from 'node:path'
|
|
5
8
|
import { pathToFileURL } from 'node:url'
|
|
@@ -8,6 +11,135 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
8
11
|
|
|
9
12
|
const execFileAsync = promisify(execFile)
|
|
10
13
|
|
|
14
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
15
|
+
|
|
16
|
+
async function waitForFile(path: string, timeoutMs: number) {
|
|
17
|
+
const started = Date.now()
|
|
18
|
+
while (true) {
|
|
19
|
+
try {
|
|
20
|
+
await readFile(path)
|
|
21
|
+
return
|
|
22
|
+
} catch {
|
|
23
|
+
if (Date.now() - started > timeoutMs) {
|
|
24
|
+
throw new Error(`timed out waiting for ${path}`)
|
|
25
|
+
}
|
|
26
|
+
await sleep(25)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// pid of a real process that has exited, so legacy file contents prove
|
|
32
|
+
// dead-owner behavior instead of trusting a made-up number
|
|
33
|
+
function deadPid(): number {
|
|
34
|
+
const probe = spawnSync('bun', ['-e', ''], { stdio: 'ignore' })
|
|
35
|
+
const pid = probe.pid as number
|
|
36
|
+
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
|
|
37
|
+
return pid
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writeStaleCache(root: string, cacheDir: string, viteDir: string) {
|
|
41
|
+
await Promise.all([
|
|
42
|
+
mkdir(cacheDir, { recursive: true }),
|
|
43
|
+
mkdir(viteDir, { recursive: true }),
|
|
44
|
+
])
|
|
45
|
+
await Promise.all([
|
|
46
|
+
writeFile(join(cacheDir, 'state.json'), JSON.stringify({ versionHash: 'old' })),
|
|
47
|
+
writeFile(join(cacheDir, 'stale'), 'stale'),
|
|
48
|
+
writeFile(join(viteDir, 'stale'), 'stale'),
|
|
49
|
+
])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// stale cache whose removal takes seconds, so a cleaner can be observed
|
|
53
|
+
// mid-hold and killed there deterministically
|
|
54
|
+
async function writeBigStaleCache(root: string, cacheDir: string, viteDir: string) {
|
|
55
|
+
const bigDir = join(viteDir, 'big')
|
|
56
|
+
await writeStaleCache(root, cacheDir, viteDir)
|
|
57
|
+
await mkdir(bigDir, { recursive: true })
|
|
58
|
+
const count = 50_000
|
|
59
|
+
const batch = 1000
|
|
60
|
+
for (let index = 0; index < count; index += batch) {
|
|
61
|
+
await Promise.all(
|
|
62
|
+
Array.from({ length: Math.min(batch, count - index) }, (_, offset) =>
|
|
63
|
+
writeFile(join(bigDir, `f${index + offset}`), 'x')
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return { bigDir, count }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cleanWorkerScript(root: string, cacheDir: string, moduleUrl: string) {
|
|
71
|
+
return `
|
|
72
|
+
import { prepareCacheForVersion } from ${JSON.stringify(moduleUrl)}
|
|
73
|
+
const cleaned = await prepareCacheForVersion({
|
|
74
|
+
root: ${JSON.stringify(root)},
|
|
75
|
+
cacheDir: ${JSON.stringify(cacheDir)},
|
|
76
|
+
versionHash: 'new',
|
|
77
|
+
})
|
|
78
|
+
console.info(cleaned ? 'cleaned-by-worker' : 'cache-already-current')
|
|
79
|
+
`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// mirrors getCacheCleanLockPort in clean.ts; must match it. the tests touch
|
|
83
|
+
// only the one port their disposable root hashes to, never the shared range.
|
|
84
|
+
const CLEAN_LOCK_PORT_BASE = 24_000
|
|
85
|
+
const CLEAN_LOCK_PORT_COUNT = 1_000
|
|
86
|
+
|
|
87
|
+
async function lockPortForRoot(root: string): Promise<number> {
|
|
88
|
+
const info = await stat(join(root, 'node_modules'))
|
|
89
|
+
let hash = 0x811c9dc5
|
|
90
|
+
const identity = `${info.dev}:${info.ino}`
|
|
91
|
+
for (let index = 0; index < identity.length; index++) {
|
|
92
|
+
hash ^= identity.charCodeAt(index)
|
|
93
|
+
hash = Math.imul(hash, 0x01000193)
|
|
94
|
+
}
|
|
95
|
+
return CLEAN_LOCK_PORT_BASE + (hash >>> 0) % CLEAN_LOCK_PORT_COUNT
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function tryConnect(port: number): Promise<Socket | undefined> {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const socket = connect({ host: '127.0.0.1', port })
|
|
101
|
+
socket.once('connect', () => resolve(socket))
|
|
102
|
+
socket.once('error', () => {
|
|
103
|
+
socket.destroy()
|
|
104
|
+
resolve(undefined)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// outermost node_modules above the test, so a bundled worker's bare
|
|
110
|
+
// imports (vite, fs-extra, ...) resolve the way the workspace lays out
|
|
111
|
+
function outermostNodeModules(from: string): string {
|
|
112
|
+
let dir = from
|
|
113
|
+
let found: string | undefined
|
|
114
|
+
while (true) {
|
|
115
|
+
const candidate = join(dir, 'node_modules')
|
|
116
|
+
if (existsSync(candidate)) {
|
|
117
|
+
found = candidate
|
|
118
|
+
}
|
|
119
|
+
const parent = dirname(dir)
|
|
120
|
+
if (parent === dir) {
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
dir = parent
|
|
124
|
+
}
|
|
125
|
+
if (!found) {
|
|
126
|
+
throw new Error('no node_modules found above the test')
|
|
127
|
+
}
|
|
128
|
+
return found
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function expectCleanedCache(cacheDir: string, viteDir: string) {
|
|
132
|
+
expect(JSON.parse(await readFile(join(cacheDir, 'state.json'), 'utf8'))).toEqual({
|
|
133
|
+
versionHash: 'new',
|
|
134
|
+
})
|
|
135
|
+
await expect(readFile(join(cacheDir, 'stale'))).rejects.toMatchObject({
|
|
136
|
+
code: 'ENOENT',
|
|
137
|
+
})
|
|
138
|
+
await expect(readFile(join(viteDir, 'stale'))).rejects.toMatchObject({
|
|
139
|
+
code: 'ENOENT',
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
11
143
|
describe('prepareCacheForVersion', () => {
|
|
12
144
|
let root: string | undefined
|
|
13
145
|
|
|
@@ -75,8 +207,373 @@ describe('prepareCacheForVersion', () => {
|
|
|
75
207
|
await expect(readFile(join(viteDir, 'stale'))).rejects.toMatchObject({
|
|
76
208
|
code: 'ENOENT',
|
|
77
209
|
})
|
|
78
|
-
await expect(
|
|
79
|
-
readFile(join(root, 'node_modules', '.vxrn-clean.lock'))
|
|
80
|
-
).rejects.toMatchObject({ code: 'ENOENT' })
|
|
81
210
|
}, 20_000)
|
|
211
|
+
|
|
212
|
+
it('excludes pathname aliases of the same physical cache', async () => {
|
|
213
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-alias-clean-'))
|
|
214
|
+
const realRoot = join(root, 'real')
|
|
215
|
+
const aliasRoot = join(root, 'alias')
|
|
216
|
+
const cacheDir = join(realRoot, 'node_modules', '.vxrn')
|
|
217
|
+
const viteDir = join(realRoot, 'node_modules', '.vite')
|
|
218
|
+
const aliasCacheDir = join(aliasRoot, 'node_modules', '.vxrn')
|
|
219
|
+
const participantsDir = join(root, 'participants')
|
|
220
|
+
const participantCount = 2
|
|
221
|
+
await writeStaleCache(realRoot, cacheDir, viteDir)
|
|
222
|
+
await mkdir(participantsDir, { recursive: true })
|
|
223
|
+
await symlink(realRoot, aliasRoot)
|
|
224
|
+
// a dead owner's corpse routes both contenders around file creation, so
|
|
225
|
+
// only the canonical port identity can serialize them
|
|
226
|
+
const corpse = `${deadPid()}\n`
|
|
227
|
+
await writeFile(join(realRoot, 'node_modules', '.vxrn-clean.lock'), corpse, { flag: 'wx' })
|
|
228
|
+
|
|
229
|
+
const moduleUrl = pathToFileURL(join(dirname(import.meta.filename), 'clean.ts')).href
|
|
230
|
+
const workerScript = (participant: number, workerCacheDir: string) => `
|
|
231
|
+
import { readdir, writeFile } from 'node:fs/promises'
|
|
232
|
+
import { join } from 'node:path'
|
|
233
|
+
import { prepareCacheForVersion } from ${JSON.stringify(moduleUrl)}
|
|
234
|
+
|
|
235
|
+
const participantsDir = ${JSON.stringify(participantsDir)}
|
|
236
|
+
await writeFile(join(participantsDir, ${JSON.stringify(String(participant))}), '')
|
|
237
|
+
while ((await readdir(participantsDir)).length < ${participantCount}) {
|
|
238
|
+
await Bun.sleep(5)
|
|
239
|
+
}
|
|
240
|
+
const cleaned = await prepareCacheForVersion({
|
|
241
|
+
root: ${JSON.stringify(realRoot)},
|
|
242
|
+
cacheDir: ${JSON.stringify(workerCacheDir)},
|
|
243
|
+
versionHash: 'new',
|
|
244
|
+
})
|
|
245
|
+
console.info(cleaned ? 'cleaned-by-worker' : 'cache-already-current')
|
|
246
|
+
`
|
|
247
|
+
|
|
248
|
+
const results = await Promise.all([
|
|
249
|
+
execFileAsync('bun', ['-e', workerScript(0, cacheDir)], {
|
|
250
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
251
|
+
timeout: 30_000,
|
|
252
|
+
}),
|
|
253
|
+
execFileAsync('bun', ['-e', workerScript(1, aliasCacheDir)], {
|
|
254
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
255
|
+
timeout: 30_000,
|
|
256
|
+
}),
|
|
257
|
+
])
|
|
258
|
+
const output = results.map(({ stdout }) => stdout).join('\n')
|
|
259
|
+
|
|
260
|
+
expect(output.match(/\[vxrn\] cleaning/g)).toHaveLength(1)
|
|
261
|
+
expect(output.match(/cleaned-by-worker/g)).toHaveLength(1)
|
|
262
|
+
await expectCleanedCache(cacheDir, viteDir)
|
|
263
|
+
expect(await readFile(join(realRoot, 'node_modules', '.vxrn-clean.lock'), 'utf8')).toBe(corpse)
|
|
264
|
+
}, 60_000)
|
|
265
|
+
|
|
266
|
+
it('lets the next cleaner proceed after the holder dies mid-hold', async () => {
|
|
267
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-holder-death-'))
|
|
268
|
+
const cacheDir = join(root, 'node_modules', '.vxrn')
|
|
269
|
+
const viteDir = join(root, 'node_modules', '.vite')
|
|
270
|
+
const moduleUrl = pathToFileURL(join(dirname(import.meta.filename), 'clean.ts')).href
|
|
271
|
+
const { bigDir, count } = await writeBigStaleCache(root, cacheDir, viteDir)
|
|
272
|
+
|
|
273
|
+
const holder = spawn('bun', ['-e', cleanWorkerScript(root, cacheDir, moduleUrl)], {
|
|
274
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
275
|
+
stdio: 'ignore',
|
|
276
|
+
})
|
|
277
|
+
try {
|
|
278
|
+
// the holder is inside once files start disappearing and its removal
|
|
279
|
+
// of the tiny cache dir has taken state.json with it
|
|
280
|
+
const startedAt = Date.now()
|
|
281
|
+
while (true) {
|
|
282
|
+
const remaining = await readdir(bigDir).catch(() => [] as string[])
|
|
283
|
+
const stateGone = await readFile(join(cacheDir, 'state.json')).then(
|
|
284
|
+
() => false,
|
|
285
|
+
() => true
|
|
286
|
+
)
|
|
287
|
+
if (remaining.length < count && stateGone) {
|
|
288
|
+
break
|
|
289
|
+
}
|
|
290
|
+
if (Date.now() - startedAt > 15_000) {
|
|
291
|
+
throw new Error('holder never entered its critical section')
|
|
292
|
+
}
|
|
293
|
+
await sleep(25)
|
|
294
|
+
}
|
|
295
|
+
holder.kill('SIGKILL')
|
|
296
|
+
await new Promise<void>((resolve) => holder.on('exit', () => resolve()))
|
|
297
|
+
expect(await readFile(join(root, 'node_modules', '.vxrn-clean.lock'), 'utf8')).toBe(
|
|
298
|
+
`${holder.pid}\n`
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const { stdout } = await execFileAsync(
|
|
302
|
+
'bun',
|
|
303
|
+
['-e', cleanWorkerScript(root, cacheDir, moduleUrl)],
|
|
304
|
+
{
|
|
305
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
306
|
+
timeout: 30_000,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
// the successor enters past the corpse and the freed port: absent
|
|
311
|
+
// state means no version to compare, so it records the new version
|
|
312
|
+
// without cleaning rather than hanging on the dead owner
|
|
313
|
+
expect(stdout).not.toMatch('[vxrn] cleaning')
|
|
314
|
+
expect(stdout).toMatch('cache-already-current')
|
|
315
|
+
expect(JSON.parse(await readFile(join(cacheDir, 'state.json'), 'utf8'))).toEqual({
|
|
316
|
+
versionHash: 'new',
|
|
317
|
+
})
|
|
318
|
+
expect(await readFile(join(root, 'node_modules', '.vxrn-clean.lock'), 'utf8')).toBe(
|
|
319
|
+
`${holder.pid}\n`
|
|
320
|
+
)
|
|
321
|
+
} finally {
|
|
322
|
+
holder.kill('SIGKILL')
|
|
323
|
+
}
|
|
324
|
+
}, 60_000)
|
|
325
|
+
|
|
326
|
+
it('waits for a live legacy holder then cleans after release', async () => {
|
|
327
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-live-clean-'))
|
|
328
|
+
const cacheDir = join(root, 'node_modules', '.vxrn')
|
|
329
|
+
const viteDir = join(root, 'node_modules', '.vite')
|
|
330
|
+
const lockPath = join(root, 'node_modules', '.vxrn-clean.lock')
|
|
331
|
+
const readyPath = join(root, 'holder-ready')
|
|
332
|
+
const moduleUrl = pathToFileURL(join(dirname(import.meta.filename), 'clean.ts')).href
|
|
333
|
+
await writeStaleCache(root, cacheDir, viteDir)
|
|
334
|
+
|
|
335
|
+
// old-version holder: pid file plus unconditional release, no port
|
|
336
|
+
const holderScript = `
|
|
337
|
+
import { rm, writeFile } from 'node:fs/promises'
|
|
338
|
+
const lockPath = ${JSON.stringify(lockPath)}
|
|
339
|
+
await writeFile(lockPath, \`\${process.pid}\\n\`, { flag: 'wx' })
|
|
340
|
+
await writeFile(${JSON.stringify(readyPath)}, '')
|
|
341
|
+
await Bun.sleep(1500)
|
|
342
|
+
await rm(lockPath)
|
|
343
|
+
`
|
|
344
|
+
const holder = execFileAsync('bun', ['-e', holderScript])
|
|
345
|
+
await waitForFile(readyPath, 10_000)
|
|
346
|
+
|
|
347
|
+
const startedAt = Date.now()
|
|
348
|
+
const { stdout } = await execFileAsync(
|
|
349
|
+
'bun',
|
|
350
|
+
['-e', cleanWorkerScript(root, cacheDir, moduleUrl)],
|
|
351
|
+
{
|
|
352
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
353
|
+
timeout: 30_000,
|
|
354
|
+
}
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
await holder
|
|
358
|
+
// the waiter cannot finish before the 1500ms legacy hold is released
|
|
359
|
+
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(1000)
|
|
360
|
+
expect(stdout).toMatch('[vxrn] cleaning')
|
|
361
|
+
expect(stdout).toMatch('cleaned-by-worker')
|
|
362
|
+
await expectCleanedCache(cacheDir, viteDir)
|
|
363
|
+
await expect(readFile(lockPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
364
|
+
}, 60_000)
|
|
365
|
+
|
|
366
|
+
it('never enters past a lock with an unknown owner', async () => {
|
|
367
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-unknown-clean-'))
|
|
368
|
+
const cacheDir = join(root, 'node_modules', '.vxrn')
|
|
369
|
+
const viteDir = join(root, 'node_modules', '.vite')
|
|
370
|
+
const lockPath = join(root, 'node_modules', '.vxrn-clean.lock')
|
|
371
|
+
const moduleUrl = pathToFileURL(join(dirname(import.meta.filename), 'clean.ts')).href
|
|
372
|
+
await writeStaleCache(root, cacheDir, viteDir)
|
|
373
|
+
|
|
374
|
+
const pid = deadPid()
|
|
375
|
+
for (const content of ['not-a-pid\n', '0\n', `${pid}:${randomUUID()}\n`]) {
|
|
376
|
+
await writeFile(lockPath, content, { flag: 'wx' })
|
|
377
|
+
const waiter = spawn('bun', ['-e', cleanWorkerScript(root, cacheDir, moduleUrl)], {
|
|
378
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
379
|
+
stdio: 'ignore',
|
|
380
|
+
})
|
|
381
|
+
try {
|
|
382
|
+
await sleep(2000)
|
|
383
|
+
expect(waiter.exitCode).toBeNull()
|
|
384
|
+
expect(await readFile(lockPath, 'utf8')).toBe(content)
|
|
385
|
+
} finally {
|
|
386
|
+
waiter.kill('SIGKILL')
|
|
387
|
+
if (waiter.exitCode === null) {
|
|
388
|
+
await new Promise<void>((resolve) => waiter.on('exit', () => resolve()))
|
|
389
|
+
}
|
|
390
|
+
await rm(lockPath, { force: true })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
expect(JSON.parse(await readFile(join(cacheDir, 'state.json'), 'utf8'))).toEqual({
|
|
395
|
+
versionHash: 'old',
|
|
396
|
+
})
|
|
397
|
+
expect(await readFile(join(cacheDir, 'stale'), 'utf8')).toBe('stale')
|
|
398
|
+
}, 60_000)
|
|
399
|
+
|
|
400
|
+
it('cleans past a dead legacy owner without deleting its file', async () => {
|
|
401
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-dead-clean-'))
|
|
402
|
+
const cacheDir = join(root, 'node_modules', '.vxrn')
|
|
403
|
+
const viteDir = join(root, 'node_modules', '.vite')
|
|
404
|
+
const lockPath = join(root, 'node_modules', '.vxrn-clean.lock')
|
|
405
|
+
const moduleUrl = pathToFileURL(join(dirname(import.meta.filename), 'clean.ts')).href
|
|
406
|
+
await writeStaleCache(root, cacheDir, viteDir)
|
|
407
|
+
const corpse = `${deadPid()}\n`
|
|
408
|
+
await writeFile(lockPath, corpse, { flag: 'wx' })
|
|
409
|
+
|
|
410
|
+
const { stdout } = await execFileAsync(
|
|
411
|
+
'bun',
|
|
412
|
+
['-e', cleanWorkerScript(root, cacheDir, moduleUrl)],
|
|
413
|
+
{
|
|
414
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
415
|
+
timeout: 30_000,
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
expect(stdout).toMatch('[vxrn] cleaning')
|
|
420
|
+
expect(stdout).toMatch('cleaned-by-worker')
|
|
421
|
+
await expectCleanedCache(cacheDir, viteDir)
|
|
422
|
+
expect(await readFile(lockPath, 'utf8')).toBe(corpse)
|
|
423
|
+
}, 60_000)
|
|
424
|
+
|
|
425
|
+
it('releases on node while ordinary connections are held mid-hold', async () => {
|
|
426
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-node-release-'))
|
|
427
|
+
const cacheDir = join(root, 'node_modules', '.vxrn')
|
|
428
|
+
const viteDir = join(root, 'node_modules', '.vite')
|
|
429
|
+
const { bigDir, count } = await writeBigStaleCache(root, cacheDir, viteDir)
|
|
430
|
+
|
|
431
|
+
// bundle current source so plain node runs the exact corrected runtime:
|
|
432
|
+
// bun completes release with accepted connections pending, node does
|
|
433
|
+
// not unless the server rejects them at once
|
|
434
|
+
const probeDir = join(
|
|
435
|
+
outermostNodeModules(dirname(import.meta.filename)),
|
|
436
|
+
'.cache',
|
|
437
|
+
`vxrn-clean-node-${randomUUID()}`
|
|
438
|
+
)
|
|
439
|
+
const bundlePath = join(probeDir, 'clean.bundle.mjs')
|
|
440
|
+
await mkdir(probeDir, { recursive: true })
|
|
441
|
+
const held: Socket[] = []
|
|
442
|
+
const workerScript = `
|
|
443
|
+
const { prepareCacheForVersion } = await import(${JSON.stringify(
|
|
444
|
+
pathToFileURL(bundlePath).href
|
|
445
|
+
)})
|
|
446
|
+
const cleaned = await prepareCacheForVersion({
|
|
447
|
+
root: ${JSON.stringify(root)},
|
|
448
|
+
cacheDir: ${JSON.stringify(cacheDir)},
|
|
449
|
+
versionHash: 'new',
|
|
450
|
+
})
|
|
451
|
+
console.info(cleaned ? 'cleaned-by-worker' : 'cache-already-current')
|
|
452
|
+
`
|
|
453
|
+
try {
|
|
454
|
+
await execFileAsync(
|
|
455
|
+
'bun',
|
|
456
|
+
[
|
|
457
|
+
'build',
|
|
458
|
+
join(dirname(import.meta.filename), 'clean.ts'),
|
|
459
|
+
'--outfile',
|
|
460
|
+
bundlePath,
|
|
461
|
+
'--format',
|
|
462
|
+
'esm',
|
|
463
|
+
'--packages',
|
|
464
|
+
'external',
|
|
465
|
+
],
|
|
466
|
+
{ timeout: 60_000 }
|
|
467
|
+
)
|
|
468
|
+
const lockPort = await lockPortForRoot(root)
|
|
469
|
+
const occupied = await tryConnect(lockPort)
|
|
470
|
+
occupied?.destroy()
|
|
471
|
+
expect(occupied, 'exact mutex port must start free').toBeUndefined()
|
|
472
|
+
const worker = spawn('node', ['--input-type=module', '-e', workerScript], {
|
|
473
|
+
env: { ...process.env, VXRN_DONT_CLEAN_SELF: '' },
|
|
474
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
475
|
+
})
|
|
476
|
+
let stdout = ''
|
|
477
|
+
let stderr = ''
|
|
478
|
+
worker.stdout?.on('data', (data) => (stdout += data))
|
|
479
|
+
worker.stderr?.on('data', (data) => (stderr += data))
|
|
480
|
+
try {
|
|
481
|
+
let acceptedMidHold = 0
|
|
482
|
+
let midHoldSeen = false
|
|
483
|
+
const startedAt = Date.now()
|
|
484
|
+
while (worker.exitCode === null && Date.now() - startedAt < 45_000) {
|
|
485
|
+
const remaining = await readdir(bigDir).catch(() => [] as string[])
|
|
486
|
+
const stateGone = await readFile(join(cacheDir, 'state.json')).then(
|
|
487
|
+
() => false,
|
|
488
|
+
() => true
|
|
489
|
+
)
|
|
490
|
+
if (remaining.length < count && stateGone) {
|
|
491
|
+
midHoldSeen = true
|
|
492
|
+
const socket = await tryConnect(lockPort)
|
|
493
|
+
if (socket) {
|
|
494
|
+
acceptedMidHold++
|
|
495
|
+
held.push(socket)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
await sleep(150)
|
|
499
|
+
}
|
|
500
|
+
if (worker.exitCode === null) {
|
|
501
|
+
worker.kill('SIGKILL')
|
|
502
|
+
await new Promise<void>((resolve) => worker.on('exit', () => resolve()))
|
|
503
|
+
throw new Error(
|
|
504
|
+
`node worker never released with ${acceptedMidHold} connections held: ${stderr}`
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
expect(midHoldSeen).toBe(true)
|
|
508
|
+
expect(acceptedMidHold).toBeGreaterThan(0)
|
|
509
|
+
expect(worker.exitCode).toBe(0)
|
|
510
|
+
expect(stdout).toMatch('[vxrn] cleaning')
|
|
511
|
+
expect(stdout).toMatch('cleaned-by-worker')
|
|
512
|
+
await expectCleanedCache(cacheDir, viteDir)
|
|
513
|
+
} finally {
|
|
514
|
+
worker.kill('SIGKILL')
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
for (const socket of held) {
|
|
518
|
+
socket.destroy()
|
|
519
|
+
}
|
|
520
|
+
await rm(probeDir, { recursive: true, force: true })
|
|
521
|
+
}
|
|
522
|
+
}, 120_000)
|
|
523
|
+
|
|
524
|
+
it('removes its own file when port acquisition fails', async () => {
|
|
525
|
+
root = await mkdtemp(join(tmpdir(), 'vxrn-port-failure-'))
|
|
526
|
+
const cacheDir = join(root, 'node_modules', '.vxrn')
|
|
527
|
+
const viteDir = join(root, 'node_modules', '.vite')
|
|
528
|
+
const lockPath = join(root, 'node_modules', '.vxrn-clean.lock')
|
|
529
|
+
const moduleUrl = pathToFileURL(join(dirname(import.meta.filename), 'clean.ts')).href
|
|
530
|
+
await writeStaleCache(root, cacheDir, viteDir)
|
|
531
|
+
|
|
532
|
+
// squat only this root's exact mutex port so acquisition must fail
|
|
533
|
+
const squatters: Server[] = []
|
|
534
|
+
try {
|
|
535
|
+
const port = await lockPortForRoot(root)
|
|
536
|
+
await new Promise<void>((resolve, reject) => {
|
|
537
|
+
const server = createServer()
|
|
538
|
+
server.once('error', reject)
|
|
539
|
+
server.once('listening', () => {
|
|
540
|
+
squatters.push(server)
|
|
541
|
+
resolve()
|
|
542
|
+
})
|
|
543
|
+
server.listen({ port, host: '127.0.0.1' })
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
const failure = await execFileAsync(
|
|
547
|
+
'bun',
|
|
548
|
+
['-e', cleanWorkerScript(root, cacheDir, moduleUrl)],
|
|
549
|
+
{
|
|
550
|
+
env: {
|
|
551
|
+
...process.env,
|
|
552
|
+
VXRN_DONT_CLEAN_SELF: '',
|
|
553
|
+
VXRN_CLEAN_LOCK_TIMEOUT_MS: '2000',
|
|
554
|
+
},
|
|
555
|
+
timeout: 30_000,
|
|
556
|
+
}
|
|
557
|
+
).then(
|
|
558
|
+
() => undefined,
|
|
559
|
+
(err: unknown) => err as NodeJS.ErrnoException & { stderr?: string }
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
expect(failure, 'cleaner must fail while the mutex port is squatted').toBeDefined()
|
|
563
|
+
expect(String(failure?.stderr ?? failure)).toMatch(
|
|
564
|
+
'Timed out waiting for VXRN cache cleanup lock'
|
|
565
|
+
)
|
|
566
|
+
await expect(readFile(lockPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
567
|
+
expect(JSON.parse(await readFile(join(cacheDir, 'state.json'), 'utf8'))).toEqual({
|
|
568
|
+
versionHash: 'old',
|
|
569
|
+
})
|
|
570
|
+
expect(await readFile(join(cacheDir, 'stale'), 'utf8')).toBe('stale')
|
|
571
|
+
} finally {
|
|
572
|
+
await Promise.all(
|
|
573
|
+
squatters.map(
|
|
574
|
+
(server) => new Promise<void>((resolve) => server.close(() => resolve()))
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
}, 60_000)
|
|
82
579
|
})
|