diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index d5ddb0ea..7fa2543b 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -31,8 +31,4 @@ jobs: run: npm install - name: benchmark - run: node benchmark/index.js - env: - RIMRAF_TEST_START_CHAR: a - RIMRAF_TEST_END_CHAR: f - RIMRAF_TEST_DEPTH: 5 + run: node benchmark/index.js --start-char=a --end-char=f --depth=5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c0e451..05cfcf12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,21 @@ name: CI -on: [push, pull_request] +on: [pull_request] jobs: build: strategy: matrix: - node-version: [20.x, 22.x] + node-version: [22.x] platform: - - os: ubuntu-latest - shell: bash - - os: macos-latest - shell: bash + # - os: ubuntu-latest + # shell: bash + # - os: macos-latest + # shell: bash - os: windows-latest shell: bash - - os: windows-latest - shell: powershell + # - os: windows-latest + # shell: powershell fail-fast: false runs-on: ${{ matrix.platform.os }} @@ -36,8 +36,21 @@ jobs: run: npm install - name: Run Tests - run: npm test -- -t0 -c - env: - RIMRAF_TEST_START_CHAR: a - RIMRAF_TEST_END_CHAR: f - RIMRAF_TEST_DEPTH: 5 + run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async + - run: npm test -- test/integration/eperm.ts --disable-coverage --grep=. --grep=async diff --git a/.gitignore b/.gitignore index 5ae2c2bf..181a4d04 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ !/typedoc.json !/tsconfig-*.json !/.prettierignore +/benchmark-*.json diff --git a/.prettierignore b/.prettierignore index d289a030..47415c69 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,4 +8,4 @@ /tap-snapshots /.nyc_output /coverage -/benchmark +/benchmark/old-rimraf diff --git a/benchmark/create-fixture.js b/benchmark/create-fixture.js index 99e7348a..3ce172ef 100644 --- a/benchmark/create-fixture.js +++ b/benchmark/create-fixture.js @@ -1,24 +1,25 @@ -const { writeFile: writeFile_ } = require('fs') -const writeFile = async (path, data) => new Promise((res, rej) => - writeFile_(path, data, er => er ? rej(er) : res())) -const { mkdirp } = require('mkdirp') -const { resolve } = require('path') +import { writeFile as writeFile_ } from 'fs' +const writeFile = async (path, data) => + new Promise((res, rej) => + writeFile_(path, data, er => (er ? rej(er) : res())), + ) +import { mkdirp } from 'mkdirp' +import { resolve } from 'path' const create = async (path, start, end, maxDepth, depth = 0) => { await mkdirp(path) const promises = [] for (let i = start; i <= end; i++) { const c = String.fromCharCode(i) - if (depth < maxDepth && (i-start >= depth)) + if (depth < maxDepth && i - start >= depth) await create(resolve(path, c), start, end, maxDepth, depth + 1) - else - promises.push(writeFile(resolve(path, c), c)) + else promises.push(writeFile(resolve(path, c), c)) } await Promise.all(promises) return path } -module.exports = async ({ start, end, depth, name }) => { - const path = resolve(__dirname, 'fixtures', name, 'test') +export default async ({ start, end, depth, name }) => { + const path = resolve(import.meta.dirname, 'fixtures', name, 'test') return await create(path, start.charCodeAt(0), end.charCodeAt(0), depth) } diff --git a/benchmark/index.js b/benchmark/index.js index 90928a20..acf1c85a 100644 --- a/benchmark/index.js +++ b/benchmark/index.js @@ -1,18 +1,132 @@ -const cases = require('./rimrafs.js') -const runTest = require('./run-test.js') -const print = require('./print-results.js') +import rimrafs, { names as rimrafNames } from './rimrafs.js' +import runTest, { names as runTestNames } from './run-test.js' +import parse from './parse-results.js' +import { sync as rimrafSync } from '../dist/esm/index.js' +import { parseArgs } from 'util' +import assert from 'assert' +import { readFileSync, writeFileSync } from 'fs' + +const parseOptions = () => { + const { values } = parseArgs({ + options: { + cases: { + type: 'string', + short: 'c', + multiple: true, + }, + 'omit-cases': { + type: 'string', + short: 'o', + multiple: true, + }, + 'start-char': { + type: 'string', + default: 'a', + }, + 'end-char': { + type: 'string', + default: 'f', + }, + depth: { + type: 'string', + default: '5', + }, + iterations: { + type: 'string', + default: '7', + }, + compare: { + type: 'string', + }, + save: { + type: 'boolean', + }, + }, + }) + + if (values.compare) { + const { results, options } = JSON.parse( + readFileSync(values.compare, 'utf8'), + ) + return { + ...options, + save: false, + compare: results, + } + } + + const allNames = new Set([...rimrafNames, ...runTestNames]) + const partition = (name, defaults = [new Set(), new Set()]) => { + const options = values[name] ?? [] + assert( + options.every(c => allNames.has(c)), + new TypeError(`invalid ${name}`, { + cause: { + found: options, + wanted: [...allNames], + }, + }), + ) + const found = options.reduce( + (acc, k) => { + acc[rimrafNames.has(k) ? 0 : 1].add(k) + return acc + }, + [new Set(), new Set()], + ) + return [ + found[0].size ? found[0] : defaults[0], + found[1].size ? found[1] : defaults[1], + ] + } + + const cases = partition('cases', [rimrafNames, runTestNames]) + for (const [i, omitCase] of Object.entries(partition('omit-cases'))) { + for (const o of omitCase) { + cases[i].delete(o) + } + } + + return { + rimraf: [...cases[0]], + runTest: [...cases[1]], + start: values['start-char'], + end: values['end-char'], + depth: +values.depth, + iterations: +values.iterations, + save: values.save, + compare: null, + } +} -const rimraf = require('../') const main = async () => { // cleanup first. since the windows impl works on all platforms, // use that. it's only relevant if the folder exists anyway. - rimraf.sync(__dirname + '/fixtures') - const results = {} - for (const name of Object.keys(cases)) { - results[name] = await runTest(name) + rimrafSync(import.meta.dirname + '/fixtures') + const data = {} + const { save, compare, ...options } = parseOptions() + for (const [name, rimraf] of Object.entries(rimrafs)) { + if (options.rimraf.includes(name)) { + data[name] = await runTest(name, rimraf, options) + } + } + rimrafSync(import.meta.dirname + '/fixtures') + const { results, entries } = parse(data, compare) + if (save) { + const f = `benchmark-${Date.now()}.json` + writeFileSync(f, JSON.stringify({ options, results }, 0, 2)) + console.log(`results saved to ${f}`) + } else { + console.log(JSON.stringify(results, null, 2)) } - rimraf.sync(__dirname + '/fixtures') - return results + console.table( + entries + .sort(([, { mean: a }], [, { mean: b }]) => a - b) + .reduce((set, [key, val]) => { + set[key] = val + return set + }, {}), + ) } -main().then(print) +main() diff --git a/benchmark/parse-results.js b/benchmark/parse-results.js new file mode 100644 index 00000000..12d39dbf --- /dev/null +++ b/benchmark/parse-results.js @@ -0,0 +1,64 @@ +const sum = list => list.reduce((a, b) => a + b) +const mean = list => sum(list) / list.length +const median = list => list.sort()[Math.floor(list.length / 2)] +const max = list => list.sort()[list.length - 1] +const min = list => list.sort()[0] +const sqrt = n => Math.pow(n, 0.5) +const variance = list => { + const m = mean(list) + return mean(list.map(n => Math.pow(n - m, 2))) +} +const stddev = list => { + const v = variance(list) + if (isNaN(v)) { + throw new Error('wat?', { cause: { list, v } }) + } + return sqrt(variance(list)) +} +const comp = (v1, v2) => { + if (v1 === undefined) { + return {} + } + return { + 'old mean': v1.mean, + '% +/-': round(((v2.mean - v1.mean) / v1.mean) * 100), + } +} + +const round = n => Math.round(n * 1e3) / 1e3 + +const nums = list => ({ + mean: round(mean(list)), + median: round(median(list)), + stddev: round(stddev(list)), + max: round(max(list)), + min: round(min(list)), +}) + +const printEr = er => `${er.code ? er.code + ': ' : ''}${er.message}` + +const parseResults = (data, compare) => { + const results = {} + const table = {} + + for (const [rimrafName, rimrafData] of Object.entries(data)) { + results[rimrafName] = {} + for (const [runTestName, { times, fails }] of Object.entries(rimrafData)) { + const result = nums(times) + const failures = fails.map(printEr) + results[rimrafName][runTestName] = { ...result, times, failures } + table[`${rimrafName} ${runTestName}`] = { + ...result, + ...comp(compare?.[rimrafName]?.[runTestName], result), + ...(failures.length ? { failures: failures.join('\n') } : {}), + } + } + } + + return { + results, + entries: Object.entries(table), + } +} + +export default parseResults diff --git a/benchmark/print-results.js b/benchmark/print-results.js deleted file mode 100644 index 831e3309..00000000 --- a/benchmark/print-results.js +++ /dev/null @@ -1,65 +0,0 @@ -const sum = list => list.reduce((a, b) => a + b) -const mean = list => sum(list) / list.length -const median = list => list.sort()[Math.floor(list.length / 2)] -const max = list => list.sort()[list.length - 1] -const min = list => list.sort()[0] -const sqrt = n => Math.pow(n, 0.5) -const variance = list => { - const m = mean(list) - return mean(list.map(n => Math.pow(n - m, 2))) -} -const stddev = list => { - const v = variance(list) - if (isNaN(v)) { - console.error({list, v}) - throw new Error('wat?') - } - return sqrt(variance(list)) -} - -const round = n => Math.round(n * 1e3) / 1e3 - -const nums = list => ({ - mean: round(mean(list)), - median: round(median(list)), - stddev: round(stddev(list)), - max: round(max(list)), - min: round(min(list)), -}) - -const printEr = er => `${er.code ? er.code + ': ' : ''}${er.message}` -const failures = list => list.length === 0 ? {} - : { failures: list.map(er => printEr(er)).join('\n') } - -const table = results => { - const table = {} - for (const [type, data] of Object.entries(results)) { - table[`${type} sync`] = { - ...nums(data.syncTimes), - ...failures(data.syncFails), - } - table[`${type} async`] = { - ...nums(data.asyncTimes), - ...failures(data.asyncFails), - } - table[`${type} parallel`] = { - ...nums(data.paraTimes), - ...failures(data.paraFails), - } - } - // sort by mean time - return Object.entries(table) - .sort(([, {mean:a}], [, {mean:b}]) => a - b) - .reduce((set, [key, val]) => { - set[key] = val - return set - }, {}) -} - -const print = results => { - console.log(JSON.stringify(results, 0, 2)) - console.log('Results sorted by fastest mean value') - console.table(table(results)) -} - -module.exports = print diff --git a/benchmark/rimrafs.js b/benchmark/rimrafs.js index aefbabfe..8de19cea 100644 --- a/benchmark/rimrafs.js +++ b/benchmark/rimrafs.js @@ -1,24 +1,25 @@ // just disable the glob option, and promisify it, for apples-to-apples comp +import { promisify } from 'util' +import { createRequire } from 'module' const oldRimraf = () => { - const {promisify} = require('util') - const oldRimraf = require('./old-rimraf') + const oldRimraf = createRequire(import.meta.filename)('./old-rimraf') const pOldRimraf = promisify(oldRimraf) const rimraf = path => pOldRimraf(path, { disableGlob: true }) const sync = path => oldRimraf.sync(path, { disableGlob: true }) return Object.assign(rimraf, { sync }) } -const { spawn, spawnSync } = require('child_process') +import { spawn, spawnSync } from 'child_process' const systemRmRf = () => { - const rimraf = path => new Promise((res, rej) => { - const proc = spawn('rm', ['-rf', path]) - proc.on('close', (code, signal) => { - if (code || signal) - rej(Object.assign(new Error('command failed'), { code, signal })) - else - res() + const rimraf = path => + new Promise((res, rej) => { + const proc = spawn('rm', ['-rf', path]) + proc.on('close', (code, signal) => { + if (code || signal) + rej(Object.assign(new Error('command failed'), { code, signal })) + else res() + }) }) - }) rimraf.sync = path => { const result = spawnSync('rm', ['-rf', path]) if (result.status || result.signal) { @@ -31,10 +32,13 @@ const systemRmRf = () => { return rimraf } -module.exports = { - native: require('../').native, - posix: require('../').posix, - windows: require('../').windows, +import { native, posix, windows } from 'rimraf' +const cases = { + native, + posix, + windows, old: oldRimraf(), system: systemRmRf(), } +export const names = new Set(Object.keys(cases)) +export default cases diff --git a/benchmark/run-test.js b/benchmark/run-test.js index a9fe6da4..f64a74ee 100644 --- a/benchmark/run-test.js +++ b/benchmark/run-test.js @@ -1,100 +1,135 @@ -const START = process.env.RIMRAF_TEST_START_CHAR || 'a' -const END = process.env.RIMRAF_TEST_END_CHAR || 'f' -const DEPTH = +process.env.RIMRAF_TEST_DEPTH || 5 -const N = +process.env.RIMRAF_TEST_ITERATIONS || 7 +import create from './create-fixture.js' -const cases = require('./rimrafs.js') - -const create = require('./create-fixture.js') - -const hrToMS = hr => Math.round(hr[0]*1e9 + hr[1]) / 1e6 +const TESTS = { + sync: 'sync', + async: 'async', + parallel: 'parallel', +} -const runTest = async (type) => { - const rimraf = cases[type] - if (!rimraf) - throw new Error('unknown rimraf type: ' + type) +const hrToMS = hr => Math.round(hr[0] * 1e9 + hr[1]) / 1e6 - const opt = { - start: START, - end: END, - depth: DEPTH, - } - console.error(`\nrunning test for ${type}, iterations=${N} %j...`, opt) +const runTest = async ( + type, + rimraf, + { runTest: cases, start, end, depth, iterations }, +) => { + console.error(`\nrunning test for ${type}, %j`, { + start, + end, + depth, + iterations, + }) // first, create all fixtures const syncPaths = [] const asyncPaths = [] const paraPaths = [] - process.stderr.write('creating fixtures...') - for (let i = 0; i < N; i++) { + process.stderr.write('creating fixtures') + for (let i = 0; i < iterations; i++) { const [syncPath, asyncPath, paraPath] = await Promise.all([ - create({ name: `${type}/sync/${i}`, ...opt }), - create({ name: `${type}/async/${i}`, ...opt }), - create({ name: `${type}/para/${i}`, ...opt }), + cases.includes(TESTS.sync) ? + create({ name: `${type}/sync/${i}`, start, end, depth }) + : null, + cases.includes(TESTS.async) ? + create({ name: `${type}/async/${i}`, start, end, depth }) + : null, + cases.includes(TESTS.parallel) ? + create({ name: `${type}/para/${i}`, start, end, depth }) + : null, ]) - syncPaths.push(syncPath) - asyncPaths.push(asyncPath) - paraPaths.push(paraPath) + syncPath && syncPaths.push(syncPath) + asyncPath && asyncPaths.push(asyncPath) + paraPath && paraPaths.push(paraPath) process.stderr.write('.') } - console.error('done!') + process.stderr.write('done!\n') const syncTimes = [] const syncFails = [] - process.stderr.write('running sync tests...') - const startSync = process.hrtime() - for (const path of syncPaths) { - const start = process.hrtime() - try { - rimraf.sync(path) - syncTimes.push(hrToMS(process.hrtime(start))) - } catch (er) { - syncFails.push(er) + if (syncPaths.length) { + process.stderr.write('running sync tests') + const startSync = process.hrtime() + for (const path of syncPaths) { + const start = process.hrtime() + try { + rimraf.sync(path) + syncTimes.push(hrToMS(process.hrtime(start))) + } catch (er) { + syncFails.push(er) + } + process.stderr.write('.') } - process.stderr.write('.') + const syncTotal = hrToMS(process.hrtime(startSync)) + console.error('done! (%j ms, %j failed)', syncTotal, syncFails.length) } - const syncTotal = hrToMS(process.hrtime(startSync)) - console.error('done! (%j ms, %j failed)', syncTotal, syncFails.length) const asyncTimes = [] const asyncFails = [] - process.stderr.write('running async tests...') - const startAsync = process.hrtime() - for (const path of asyncPaths) { - const start = process.hrtime() - await rimraf(path).then( - () => asyncTimes.push(hrToMS(process.hrtime(start))), - er => asyncFails.push(er) - ).then(() => process.stderr.write('.')) + if (asyncPaths.length) { + process.stderr.write('running async tests') + const startAsync = process.hrtime() + for (const path of asyncPaths) { + const start = process.hrtime() + await rimraf(path).then( + () => asyncTimes.push(hrToMS(process.hrtime(start))), + er => asyncFails.push(er), + ) + process.stderr.write('.') + } + const asyncTotal = hrToMS(process.hrtime(startAsync)) + console.error('done! (%j ms, %j failed)', asyncTotal, asyncFails.length) } - const asyncTotal = hrToMS(process.hrtime(startAsync)) - console.error('done! (%j ms, %j failed)', asyncTotal, asyncFails.length) const paraTimes = [] const paraFails = [] - process.stderr.write('running parallel tests...') - const startPara = process.hrtime() - const paraRuns = [] - for (const path of paraPaths) { - const start = process.hrtime() - paraRuns.push(rimraf(path).then( - () => paraTimes.push(hrToMS(process.hrtime(start))), - er => paraFails.push(er) - ).then(() => process.stderr.write('.'))) + if (paraPaths.length) { + process.stderr.write('running parallel tests') + const startPara = process.hrtime() + const paraRuns = [] + for (const path of paraPaths) { + process.stderr.write('.') + const start = process.hrtime() + paraRuns.push( + rimraf(path).then( + () => paraTimes.push(hrToMS(process.hrtime(start))), + er => paraFails.push(er), + ), + ) + } + await Promise.all(paraRuns) + const paraTotal = hrToMS(process.hrtime(startPara)) + console.error('done! (%j ms, %j failed)', paraTotal, paraFails.length) } - await Promise.all(paraRuns) - const paraTotal = hrToMS(process.hrtime(startPara)) - console.error('done! (%j ms, %j failed)', paraTotal, paraFails.length) + process.stderr.write('\n') // wait a tick to let stderr to clear return Promise.resolve().then(() => ({ - syncTimes, - syncFails, - asyncTimes, - asyncFails, - paraTimes, - paraFails, + ...(syncPaths.length ? + { + sync: { + times: syncTimes, + fails: syncFails, + }, + } + : {}), + ...(asyncPaths.length ? + { + async: { + times: asyncTimes, + fails: asyncFails, + }, + } + : {}), + ...(paraPaths.length ? + { + parallel: { + times: paraTimes, + fails: paraFails, + }, + } + : {}), })) } -module.exports = runTest +export const names = new Set(Object.values(TESTS)) +export default runTest diff --git a/package.json b/package.json index 68b06e17..358c9444 100644 --- a/package.json +++ b/package.json @@ -85,5 +85,8 @@ "rmdir", "recursive" ], + "tap": { + "timeout": 600 + }, "module": "./dist/esm/index.js" } diff --git a/src/fix-eperm.ts b/src/fix-eperm.ts index 5e7d5fed..6fc18887 100644 --- a/src/fix-eperm.ts +++ b/src/fix-eperm.ts @@ -20,7 +20,12 @@ export const fixEPERM = } throw er } - return await fn(path) + try { + return await fn(path) + } catch (er3) { + console.log('fixEPERM after chmox', er3) + throw er3 + } } throw er } diff --git a/src/fs.ts b/src/fs.ts index 12ff2077..84f32844 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -90,3 +90,35 @@ export const promises = { lstat, unlink, } + +// import fs, { Dirent } from 'fs' +// import fsPromises from 'fs/promises' + +// export { +// chmodSync, +// mkdirSync, +// renameSync, +// rmdirSync, +// rmSync, +// statSync, +// lstatSync, +// unlinkSync, +// } from 'fs' + +// export const readdirSync = (path: fs.PathLike): Dirent[] => +// fs.readdirSync(path, { withFileTypes: true }) + +// const readdir = (path: fs.PathLike): Promise => +// fsPromises.readdir(path, { withFileTypes: true }) + +// export const promises = { +// chmod: fsPromises.chmod, +// mkdir: fsPromises.mkdir, +// readdir, +// rename: fsPromises.rename, +// rm: fsPromises.rm, +// rmdir: fsPromises.rmdir, +// stat: fsPromises.stat, +// lstat: fsPromises.lstat, +// unlink: fsPromises.unlink, +// } diff --git a/src/rimraf-windows.ts b/src/rimraf-windows.ts index 30624986..889798d4 100644 --- a/src/rimraf-windows.ts +++ b/src/rimraf-windows.ts @@ -3,8 +3,8 @@ // 1. EBUSY, ENFILE, EMFILE trigger retries and/or exponential backoff // 2. All non-directories are removed first and then all directories are // removed in a second sweep. -// 3. If we hit ENOTEMPTY in the second sweep, fall back to move-remove on -// the that folder. +// 3. If we hit ENOTEMPTY or EPERM in the second sweep, fall back to +// move-remove on the that folder. // // Note: "move then remove" is 2-10 times slower, and just as unreliable. @@ -38,7 +38,8 @@ const rimrafWindowsDirMoveRemoveFallback = async ( try { return await rimrafWindowsDirRetry(path, options) } catch (er) { - if ((er as NodeJS.ErrnoException)?.code === 'ENOTEMPTY') { + const code = (er as NodeJS.ErrnoException)?.code + if (code === 'ENOTEMPTY' || code === 'EPERM') { return await rimrafMoveRemove(path, options) } throw er @@ -57,8 +58,8 @@ const rimrafWindowsDirMoveRemoveFallbackSync = ( try { return rimrafWindowsDirRetrySync(path, options) } catch (er) { - const fer = er as NodeJS.ErrnoException - if (fer?.code === 'ENOTEMPTY') { + const code = (er as NodeJS.ErrnoException)?.code + if (code === 'ENOTEMPTY' || code === 'EPERM') { return rimrafMoveRemoveSync(path, options) } throw er @@ -112,6 +113,10 @@ const rimrafWindowsDir = async ( if (entries.code === 'ENOENT') { return true } + if (entries.code === 'EPERM') { + await ignoreENOENT(rimrafWindowsDirMoveRemoveFallback(path, opt)) + return true + } if (entries.code !== 'ENOTDIR') { throw entries } @@ -121,7 +126,14 @@ const rimrafWindowsDir = async ( return false } // is a file - await ignoreENOENT(rimrafWindowsFile(path, opt)) + try { + await ignoreENOENT(rimrafWindowsFile(path, opt)) + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'EPERM') { + console.trace(err) + } + throw err + } return true } @@ -166,6 +178,12 @@ const rimrafWindowsDirSync = ( if (entries.code === 'ENOENT') { return true } + if (entries.code === 'EPERM') { + ignoreENOENTSync(() => { + rimrafWindowsDirMoveRemoveFallbackSync(path, opt) + }) + return true + } if (entries.code !== 'ENOTDIR') { throw entries } diff --git a/test/integration/eperm.ts b/test/integration/eperm.ts new file mode 100644 index 00000000..419b6909 --- /dev/null +++ b/test/integration/eperm.ts @@ -0,0 +1,193 @@ +import t, { Test } from 'tap' +import { mkdirSync, readdirSync, writeFileSync } from 'fs' +import { sep, join } from 'path' +import { globSync } from 'glob' +import { windows, windowsSync } from '../../src/index.js' +import { randomBytes } from 'crypto' +import assert from 'assert' + +const arrSame = (arr1: string[], arr2: string[]) => { + const s = (a: string[]) => [...a].sort().join(',') + return s(arr1) === s(arr2) +} + +const setup = ( + t: Test, + { + iterations, + depth, + files: fileCount, + fileKb, + }: { + iterations: number + depth: number + files: number + fileKb: number + }, +) => { + const dir = t.testdir() + + const letters = (length: number) => + Array.from({ length }).map((_, i) => (10 + i).toString(36)) + const files = letters(fileCount).map(f => `file_${f}`) + const dirs = join(...letters(depth)) + .split(sep) + .reduce((acc, d) => acc.concat(join(acc.at(-1) ?? '', d)), []) + const entries = dirs + .flatMap(d => [d, ...files.map(f => join(d, f))]) + .map(d => join(dir, d)) + + let iteration = 0 + return function* () { + while (iteration !== iterations) { + // use custom error to throw instead of using tap assertions to cut down + // on output when running many iterations + class RunError extends Error { + constructor(message: string, c?: Record) { + super(message, { + cause: { + testName: t.name, + iteration, + ...c, + }, + }) + } + } + + const actual = readdirSync(dir) + assert( + !actual.length, + new RunError(`dir is not empty`, { + found: actual, + }), + ) + + mkdirSync(join(dir, dirs.at(-1)!), { recursive: true }) + for (const d of dirs) { + for (const f of files) { + writeFileSync(join(dir, d, f), randomBytes(1024 * fileKb)) + } + } + + // randomize results from glob so that when running Promise.all(rimraf) + // on the result it will potentially delete parent directories before + // child directories and their files. This seems to make EPERM errors + // more likely on Windows. + const matches = globSync('**/*', { cwd: dir }) + .sort(() => 0.5 - Math.random()) + .map(m => join(dir, m)) + + assert( + arrSame(matches, entries), + new RunError(`glob result does not match expected`, { + found: matches, + wanted: entries, + }), + ) + + iteration += 1 + + yield [matches, RunError] as const + } + + return { + contents: readdirSync(dir), + iteration, + iterations, + } + } +} + +// Copied from sindresorhus/del since it was reported in +// https://github.com/isaacs/rimraf/pull/314 that this test would throw EPERM +// errors consistently in Windows CI environments. +// https://github.com/sindresorhus/del/blob/chore/update-deps/test.js#L116 +t.test('windows does not throw EPERM', t => { + const options = + process.env.CI ? + { + iterations: 1000, + depth: 15, + files: 7, + fileKb: 100, + } + : { + iterations: 200, + depth: 8, + files: 3, + fileKb: 10, + } + + t.test('sync', t => { + let i + const r = setup(t, options)() + while ((i = r.next())) { + if (i.done) { + i = i.value + break + } + + const [matches, RunError] = i.value + const result = matches + .map(path => { + try { + return { + path, + deleted: windowsSync(path), + } + } catch (error) { + throw new RunError('rimraf error', { error, path }) + } + }) + .filter(({ deleted }) => deleted !== true) + assert( + !result.length, + new RunError(`some entries were not deleted`, { + found: result, + }), + ) + } + + t.strictSame(i.contents, []) + t.equal(i.iteration, i.iterations, `ran all ${i.iteration} iterations`) + t.end() + }) + + t.test('async', async t => { + let i + const r = setup(t, options)() + while ((i = r.next())) { + if (i.done) { + i = i.value + break + } + + const [matches, RunError] = i.value + const result = ( + await Promise.all( + matches.map(async path => { + try { + return { + path, + deleted: await windows(path), + } + } catch (error) { + throw new RunError('rimraf error', { error, path }) + } + }), + ) + ).filter(({ deleted }) => deleted !== true) + assert( + !result.length, + new RunError(`some entries were not deleted`, { + found: result, + }), + ) + } + + t.strictSame(i.contents, []) + t.equal(i.iteration, i.iterations, `ran all ${i.iteration} iterations`) + }) + + t.end() +})