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.
Files changed (52) hide show
  1. package/dist/exports/clean.mjs +128 -19
  2. package/dist/exports/clean.mjs.map +1 -1
  3. package/dist/exports/clean.native.js +147 -21
  4. package/dist/exports/clean.native.js.map +1 -1
  5. package/dist/exports/prebuild.mjs +2 -1
  6. package/dist/exports/prebuild.mjs.map +1 -1
  7. package/dist/exports/prebuild.native.js +2 -1
  8. package/dist/exports/prebuild.native.js.map +1 -1
  9. package/dist/exports/prebuildWithoutExpo.mjs +196 -7
  10. package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
  11. package/dist/exports/prebuildWithoutExpo.native.js +280 -42
  12. package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
  13. package/dist/plugins/reactNativeDevServer.mjs +1 -1
  14. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  15. package/dist/plugins/reactNativeDevServer.native.js +1 -1
  16. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  17. package/dist/user-interface/index.mjs +31 -24
  18. package/dist/user-interface/index.mjs.map +1 -1
  19. package/dist/user-interface/index.native.js +32 -25
  20. package/dist/user-interface/index.native.js.map +1 -1
  21. package/dist/utils/createNativeDevEngine.mjs +111 -14
  22. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  23. package/dist/utils/createNativeDevEngine.native.js +143 -14
  24. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  25. package/dist/utils/scanDepsToOptimize.mjs +10 -6
  26. package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
  27. package/dist/utils/scanDepsToOptimize.native.js +12 -6
  28. package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
  29. package/expo-plugin.cjs +71 -8
  30. package/package.json +13 -13
  31. package/src/exports/clean.test.ts +502 -5
  32. package/src/exports/clean.ts +176 -19
  33. package/src/exports/expoPlugin.test.mjs +44 -1
  34. package/src/exports/prebuild.ts +6 -0
  35. package/src/exports/prebuildWithoutExpo.test.ts +134 -2
  36. package/src/exports/prebuildWithoutExpo.ts +235 -10
  37. package/src/plugins/reactNativeDevServer.ts +1 -1
  38. package/src/user-interface/index.ts +50 -42
  39. package/src/utils/createNativeDevEngine.test.ts +480 -1
  40. package/src/utils/createNativeDevEngine.ts +220 -17
  41. package/src/utils/imageSizePatch.test.ts +3 -3
  42. package/src/utils/scanDepsToOptimize.test.ts +35 -2
  43. package/src/utils/scanDepsToOptimize.ts +25 -8
  44. package/types/exports/clean.d.ts.map +1 -1
  45. package/types/exports/prebuild.d.ts +2 -1
  46. package/types/exports/prebuild.d.ts.map +1 -1
  47. package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
  48. package/types/user-interface/index.d.ts.map +1 -1
  49. package/types/utils/createNativeDevEngine.d.ts +1 -1
  50. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
  51. package/types/utils/scanDepsToOptimize.d.ts +25 -0
  52. package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
@@ -1,5 +1,8 @@
1
- import { execFile } from 'node:child_process'
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
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
  })