From c894f7eabb07a2d2e4d40ba56a4464126671ff22 Mon Sep 17 00:00:00 2001 From: hrmny Date: Thu, 23 Nov 2023 17:07:58 +0100 Subject: [PATCH] fix(windows): workaround for intermittent locks on windows when renaming files --- packages/next/src/lib/fs/rename.ts | 106 ++++++++++++++++++ packages/next/src/lib/fs/write-atomic.ts | 20 ++++ .../lib/router-utils/setup-dev-bundler.ts | 19 +--- 3 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 packages/next/src/lib/fs/rename.ts create mode 100644 packages/next/src/lib/fs/write-atomic.ts diff --git a/packages/next/src/lib/fs/rename.ts b/packages/next/src/lib/fs/rename.ts new file mode 100644 index 0000000000000..32bd4c7b338d3 --- /dev/null +++ b/packages/next/src/lib/fs/rename.ts @@ -0,0 +1,106 @@ +/* +MIT License + +Copyright (c) 2015 - present Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +// This file is based on https://github.com/microsoft/vscode/blob/f860fcf11022f10a992440fd54c6e45674e39617/src/vs/base/node/pfs.ts +// See the LICENSE at the top of the file + +import * as fs from 'fs-extra' + +/** + * A drop-in replacement for `fs.rename` that: + * - allows to move across multiple disks + * - attempts to retry the operation for certain error codes on Windows + */ +export async function rename( + source: string, + target: string, + windowsRetryTimeout: number | false = 60000 /* matches graceful-fs */ +): Promise { + if (source === target) { + return // simulate node.js behaviour here and do a no-op if paths match + } + + if (process.platform === 'win32' && typeof windowsRetryTimeout === 'number') { + // On Windows, a rename can fail when either source or target + // is locked by AV software. We do leverage fs-extra/graceful-fs to iron + // out these issues, however in case the target file exists, + // fs-extra/graceful-fs will immediately return without retry for fs.rename(). + await renameWithRetry(source, target, Date.now(), windowsRetryTimeout) + } else { + await fs.rename(source, target) + } +} + +async function renameWithRetry( + source: string, + target: string, + startTime: number, + retryTimeout: number, + attempt = 0 +): Promise { + try { + return await fs.rename(source, target) + } catch (error: any) { + if ( + error.code !== 'EACCES' && + error.code !== 'EPERM' && + error.code !== 'EBUSY' + ) { + throw error // only for errors we think are temporary + } + + if (Date.now() - startTime >= retryTimeout) { + console.error( + `[node.js fs] rename failed after ${attempt} retries with error: ${error}` + ) + + throw error // give up after configurable timeout + } + + if (attempt === 0) { + let abortRetry = false + try { + const stat = await fs.stat(target) + if (!stat.isFile()) { + abortRetry = true // if target is not a file, EPERM error may be raised and we should not attempt to retry + } + } catch (error) { + // Ignore + } + + if (abortRetry) { + throw error + } + } + + // Delay with incremental backoff up to 100ms + await timeout(Math.min(100, attempt * 10)) + + // Attempt again + return renameWithRetry(source, target, startTime, retryTimeout, attempt + 1) + } +} + +const timeout = (millis: number) => + new Promise((resolve) => setTimeout(resolve, millis)) diff --git a/packages/next/src/lib/fs/write-atomic.ts b/packages/next/src/lib/fs/write-atomic.ts new file mode 100644 index 0000000000000..fc297ae7c298c --- /dev/null +++ b/packages/next/src/lib/fs/write-atomic.ts @@ -0,0 +1,20 @@ +import { unlink, writeFile } from 'fs-extra' +import { rename } from './rename' + +export async function writeFileAtomic( + filePath: string, + content: string +): Promise { + const tempPath = filePath + '.tmp.' + Math.random().toString(36).slice(2) + try { + await writeFile(tempPath, content, 'utf-8') + await rename(tempPath, filePath) + } catch (e) { + try { + await unlink(tempPath) + } catch { + // ignore + } + throw e + } +} diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index 783632028b896..1081cb3fb6622 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -128,6 +128,7 @@ import { denormalizePagePath } from '../../../shared/lib/page-path/denormalize-p import type { LoadableManifest } from '../../load-components' import { generateRandomActionKeyRaw } from '../../app-render/action-encryption-utils' import { bold, green, red } from '../../../lib/picocolors' +import { writeFileAtomic } from '../../../lib/fs/write-atomic' const wsServer = new ws.Server({ noServer: true }) @@ -812,24 +813,6 @@ async function startWatcher(opts: SetupOpts) { return manifest } - async function writeFileAtomic( - filePath: string, - content: string - ): Promise { - const tempPath = filePath + '.tmp.' + Math.random().toString(36).slice(2) - try { - await writeFile(tempPath, content, 'utf-8') - await rename(tempPath, filePath) - } catch (e) { - try { - await unlink(tempPath) - } catch { - // ignore - } - throw e - } - } - async function writeBuildManifest( rewrites: SetupOpts['fsChecker']['rewrites'] ): Promise {