mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
feat: Acquire a lockfile on distDir in next dev and next build (#84428)
https://github.com/vercel/next.js/pull/83961 lets you run both `next dev` and `next build` at the same time, however it's still problematic to run two instances of `next dev` or two instances of `next build` at the same time with the same `distDir`. This sort of thing can happen to anyone by accident, but we've seen reports of this sort of behavior with AI agents. https://github.com/vercel/next.js/pull/84378 adds a lockfile around just the persistent cache database in Turbopack, which helps, but it's not the only reason this is problematic, so we also need a lock around all of Next.js itself. I was not able to find a cross-platform lockfile implementation in node that I felt was of sufficient quality, so on POSIX platforms this uses the recently-stabilized Rust stdlib implementation, which appears to be derived from rustc's own lockfile implementation, which has seen widespread use/deployment. On Windows, since we'd rather have an advisory lock than a mandatory one, this emulates that by instead opening a file with write permissions, and a sharing mode that prohibits shared write access. That means that other processes can safely read the (empty) lockfile without blowing up. <img width="500" src="https://github.com/user-attachments/assets/1ee4a6c6-2280-4f64-8bf2-ec5369c26db1" /> <img width="500" src="https://github.com/user-attachments/assets/2b04b566-c0b4-42ce-af34-912f3f6afa72" /> Tested on Windows as well: <img width="1224" height="690" alt="Screenshot 2025-10-07 at 5 44 33 PM" src="https://github.com/user-attachments/assets/e36085b6-9c32-40ac-aca6-4c83e2a53842" />
This commit is contained in:
Generated
+89
-36
@@ -1063,7 +1063,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
"windows-link 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2951,16 +2951,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.57"
|
||||
version = "0.1.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
|
||||
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4441,6 +4442,7 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"vergen-gitcl",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4885,7 +4887,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-targets 0.48.1",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11194,12 +11196,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.1",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11208,6 +11236,30 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.42.0"
|
||||
@@ -11238,7 +11290,7 @@ version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.1",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11265,7 +11317,7 @@ version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.2",
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11285,17 +11337,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.1"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.0",
|
||||
"windows_aarch64_msvc 0.48.0",
|
||||
"windows_i686_gnu 0.48.0",
|
||||
"windows_i686_msvc 0.48.0",
|
||||
"windows_x86_64_gnu 0.48.0",
|
||||
"windows_x86_64_gnullvm 0.48.0",
|
||||
"windows_x86_64_msvc 0.48.0",
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11316,10 +11368,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.2"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.0",
|
||||
"windows_aarch64_msvc 0.53.0",
|
||||
"windows_i686_gnu 0.53.0",
|
||||
@@ -11338,9 +11391,9 @@ checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
@@ -11362,9 +11415,9 @@ checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
@@ -11386,9 +11439,9 @@ checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
@@ -11422,9 +11475,9 @@ checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
@@ -11446,9 +11499,9 @@ checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
@@ -11470,9 +11523,9 @@ checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
@@ -11494,9 +11547,9 @@ checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.0"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
|
||||
@@ -123,6 +123,8 @@ turbopack-trace-utils = { workspace = true }
|
||||
turbopack-trace-server = { workspace = true }
|
||||
turbopack-ecmascript-plugins = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = "0.60"
|
||||
|
||||
# Dependencies for the wasm32 build.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
@@ -43,8 +43,10 @@ use swc_core::{
|
||||
base::{Compiler, TransformOutput},
|
||||
common::{FilePathMapping, SourceMap},
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod css;
|
||||
pub mod lockfile;
|
||||
pub mod mdx;
|
||||
pub mod minify;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
mem::ManuallyDrop,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use napi::bindgen_prelude::External;
|
||||
|
||||
/// A wrapper around [`File`] that is passed to JS, and is set to `None` when [`lockfile_unlock`] is
|
||||
/// called.
|
||||
///
|
||||
/// This uses [`ManuallyDrop`] to prevent exposing close-on-drop semantics to JS, as its not
|
||||
/// idiomatic to rely on GC behaviors in JS.
|
||||
///
|
||||
/// When the file is unlocked, the file at that path will be deleted (best-effort).
|
||||
type JsLockfile = Mutex<ManuallyDrop<Option<LockfileInner>>>;
|
||||
|
||||
pub struct LockfileInner {
|
||||
file: File,
|
||||
#[cfg(not(windows))]
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[napi(ts_return_type = "{ __napiType: \"Lockfile\" } | null")]
|
||||
pub fn lockfile_try_acquire_sync(path: String) -> napi::Result<Option<External<JsLockfile>>> {
|
||||
let mut open_options = OpenOptions::new();
|
||||
open_options.write(true).create(true);
|
||||
|
||||
// On Windows, we don't use `File::lock` because that grabs a mandatory lock. That can break
|
||||
// tools or code that read the contents of the `.next` directory because the mandatory lock
|
||||
// file will fail with EBUSY when read. Instead, we open a file with write mode, but without
|
||||
// `FILE_SHARE_WRITE`. That gives us behavior closer to what we get on POSIX platforms.
|
||||
//
|
||||
// On POSIX platforms, Rust uses `flock` which creates an advisory lock, which can be
|
||||
// read/written/deleted.
|
||||
|
||||
#[cfg(windows)]
|
||||
return {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
use windows_sys::Win32::{Foundation, Storage::FileSystem};
|
||||
|
||||
open_options
|
||||
.share_mode(FileSystem::FILE_SHARE_READ | FileSystem::FILE_SHARE_DELETE)
|
||||
.custom_flags(FileSystem::FILE_FLAG_DELETE_ON_CLOSE);
|
||||
match open_options.open(&path) {
|
||||
Ok(file) => Ok(Some(External::new(Mutex::new(ManuallyDrop::new(Some(
|
||||
LockfileInner { file },
|
||||
)))))),
|
||||
Err(err)
|
||||
if err.raw_os_error()
|
||||
== Some(Foundation::ERROR_SHARING_VIOLATION.try_into().unwrap()) =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
return {
|
||||
use std::fs::TryLockError;
|
||||
|
||||
let file = open_options.open(&path)?;
|
||||
match file.try_lock() {
|
||||
Ok(_) => Ok(Some(External::new(Mutex::new(ManuallyDrop::new(Some(
|
||||
LockfileInner {
|
||||
file,
|
||||
path: path.into(),
|
||||
},
|
||||
)))))),
|
||||
Err(TryLockError::WouldBlock) => Ok(None),
|
||||
Err(TryLockError::Error(err)) => Err(err.into()),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[napi(ts_return_type = "Promise<{ __napiType: \"Lockfile\" } | null>")]
|
||||
pub async fn lockfile_try_acquire(path: String) -> napi::Result<Option<External<JsLockfile>>> {
|
||||
tokio::task::spawn_blocking(move || lockfile_try_acquire_sync(path))
|
||||
.await
|
||||
.context("panicked while attempting to acquire lockfile")?
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn lockfile_unlock_sync(
|
||||
#[napi(ts_arg_type = "{ __napiType: \"Lockfile\" }")] lockfile: External<JsLockfile>,
|
||||
) {
|
||||
// We don't need the file handle anymore, so we don't need to call `File::unlock`. Locks are
|
||||
// released during `drop`. Remove it from the `ManuallyDrop` wrapper.
|
||||
let Some(inner): Option<LockfileInner> = lockfile
|
||||
.lock()
|
||||
.expect("poisoned: another thread panicked during `lockfile_unlock_sync`?")
|
||||
.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// - We use `FILE_FLAG_DELETE_ON_CLOSE` on Windows, so we don't need to delete the file there.
|
||||
// - Ignore possible errors while removing the file, it only matters that we release the lock.
|
||||
// - Delete *before* releasing the lock to avoid race conditions where we might accidentally
|
||||
// delete another process's lockfile. This relies on POSIX semantics, letting us delete an
|
||||
// open file.
|
||||
#[cfg(not(windows))]
|
||||
let _ = std::fs::remove_file(inner.path);
|
||||
|
||||
drop(inner.file);
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn lockfile_unlock(
|
||||
#[napi(ts_arg_type = "{ __napiType: \"Lockfile\" }")] lockfile: External<JsLockfile>,
|
||||
) -> napi::Result<()> {
|
||||
Ok(
|
||||
tokio::task::spawn_blocking(move || lockfile_unlock_sync(lockfile))
|
||||
.await
|
||||
.context("panicked while attempting to unlock lockfile")?,
|
||||
)
|
||||
}
|
||||
@@ -852,5 +852,10 @@
|
||||
"851": "Pass either `webpack` or `turbopack`, not both.",
|
||||
"852": "Only custom servers can pass `webpack`, `turbo`, or `turbopack`.",
|
||||
"853": "Turbopack build failed",
|
||||
"854": "Expected a %s request header."
|
||||
"854": "Expected a %s request header.",
|
||||
"855": "`lockfileTryAcquire` is not supported by the wasm bindings.",
|
||||
"856": "`lockfileTryAcquireSync` is not supported by the wasm bindings.",
|
||||
"857": "`lockfileUnlock` is not supported by the wasm bindings.",
|
||||
"858": "`lockfileUnlockSync` is not supported by the wasm bindings.",
|
||||
"859": "An IO error occurred while attempting to create and acquire the lockfile"
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ import {
|
||||
writeRouteTypesManifest,
|
||||
writeValidatorFile,
|
||||
} from '../server/lib/router-utils/route-types-utils'
|
||||
import { Lockfile } from './lockfile'
|
||||
|
||||
type Fallback = null | boolean | string
|
||||
|
||||
@@ -1036,11 +1037,20 @@ export default async function build(
|
||||
)
|
||||
}
|
||||
|
||||
if (config.experimental.lockDistDir) {
|
||||
// This leaks the lock file descriptor. That's okay, it'll be cleaned up by the OS upon
|
||||
// process exit.
|
||||
await Lockfile.acquireWithRetriesOrExit(
|
||||
path.join(distDir, 'lock'),
|
||||
'next build'
|
||||
)
|
||||
}
|
||||
|
||||
if (config.cleanDistDir && !isGenerateMode) {
|
||||
await nextBuildSpan
|
||||
.traceChild('clean')
|
||||
.traceAsyncFn(() =>
|
||||
recursiveDeleteSyncWithAsyncRetries(distDir, /^(cache|dev)/)
|
||||
recursiveDeleteSyncWithAsyncRetries(distDir, /^(cache|dev|lock)/)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { bold, cyan } from '../lib/picocolors'
|
||||
import * as Log from './output/log'
|
||||
|
||||
import type { Binding, Lockfile as NativeLockfile } from './swc/types'
|
||||
|
||||
const RETRY_DELAY_MS = 10
|
||||
const MAX_RETRY_MS = 1000
|
||||
|
||||
/**
|
||||
* A cross-platform on-disk best-effort advisory exclusive lockfile
|
||||
* implementation.
|
||||
*
|
||||
* On Windows, this opens a file in write mode with the `FILE_SHARE_WRITE` flag
|
||||
* unset, so it still allows reading the lockfile. This avoids breaking tools
|
||||
* that read the contents of `.next`.
|
||||
*
|
||||
* On POSIX platforms, this uses `flock()` via `std::fs::File::try_lock`:
|
||||
* https://doc.rust-lang.org/std/fs/struct.File.html#method.try_lock
|
||||
*
|
||||
* On WASM, a dummy implementation is used which always "succeeds" in acquiring
|
||||
* the lock.
|
||||
*
|
||||
* This provides a more idiomatic wrapper around the lockfile APIs exposed on
|
||||
* the native bindings object.
|
||||
*
|
||||
* If this lock is not explicitly closed with `unlock`, we will:
|
||||
* - If `unlockOnExit` is set (the default), it will make a best-effort attempt
|
||||
* to unlock the lockfile using `process.on('exit', ...)`. This is preferrable
|
||||
* on Windows where it may take some time after process exit for the operating
|
||||
* system to clean up locks that are not explicitly released by the process.
|
||||
* - If we fail to ever release the lockfile, the operating system will clean up
|
||||
* the lock and file descriptor upon process exit.
|
||||
*/
|
||||
export class Lockfile {
|
||||
/**
|
||||
* The underlying `Lockfile` object returned by the native bindings.
|
||||
*
|
||||
* This can be `undefined` on wasm, where we don't acquire a real lockfile.
|
||||
*/
|
||||
private bindings: Binding
|
||||
private nativeLockfile: NativeLockfile | undefined
|
||||
private listener: NodeJS.ExitListener | undefined
|
||||
|
||||
private constructor(
|
||||
bindings: Binding,
|
||||
nativeLockfile: NativeLockfile | undefined
|
||||
) {
|
||||
this.bindings = bindings
|
||||
this.nativeLockfile = nativeLockfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to create or acquire an exclusive lockfile on disk. Lockfiles are
|
||||
* best-effort, depending on the platform.
|
||||
*
|
||||
* - If we fail to acquire the lock, we return `undefined`.
|
||||
* - If we're on wasm, this always returns a dummy `Lockfile` object.
|
||||
*/
|
||||
static async tryAcquire(
|
||||
path: string,
|
||||
unlockOnExit: boolean = true
|
||||
): Promise<Lockfile | undefined> {
|
||||
const { loadBindings } = require('./swc') as typeof import('./swc')
|
||||
// Ideally we could provide a sync version of `tryAcquire`, but
|
||||
// `loadBindings` is async. We're okay with skipping async-loaded wasm
|
||||
// bindings and the internal `loadNative` function is synchronous, but it
|
||||
// lacks some checks that `loadBindings` has.
|
||||
const bindings = await loadBindings()
|
||||
if (bindings.isWasm) {
|
||||
Log.info(
|
||||
`Skipping creating a lockfile at ${cyan(path)} because we're using WASM bindings`
|
||||
)
|
||||
return new Lockfile(bindings, undefined)
|
||||
} else {
|
||||
let nativeLockfile
|
||||
try {
|
||||
nativeLockfile = await bindings.lockfileTryAcquire(path)
|
||||
} catch (e) {
|
||||
// this happens if there's an IO error (e.g. `ENOENT`), which is
|
||||
// different than if we just didn't acquire the lock
|
||||
throw new Error(
|
||||
'An IO error occurred while attempting to create and acquire the lockfile',
|
||||
{ cause: e }
|
||||
)
|
||||
}
|
||||
if (nativeLockfile != null) {
|
||||
const jsLockfile = new Lockfile(bindings, nativeLockfile)
|
||||
if (unlockOnExit) {
|
||||
const exitListener = () => {
|
||||
// Best-Effort: If we don't do this, the operating system will
|
||||
// release the lock for us. This gives an opportunity to delete the
|
||||
// unlocked lockfile (which is not otherwise deleted on POSIX).
|
||||
//
|
||||
// This must be synchronous because `process.on('exit', ...)` is
|
||||
// synchronous.
|
||||
jsLockfile.unlockSync()
|
||||
}
|
||||
process.on('exit', exitListener)
|
||||
jsLockfile.listener = exitListener
|
||||
}
|
||||
return jsLockfile
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to create or acquire a lockfile using `Lockfile.tryAcquire`. If
|
||||
* that returns `undefined`, indicating that another process or caller has the
|
||||
* lockfile, then this will output an error message and exit the process with
|
||||
* a non-zero exit code.
|
||||
*
|
||||
* This will retry a small number of times. This can be useful when running
|
||||
* processes in a loop, e.g. if cleanup isn't fully synchronous due to child
|
||||
* parent/processes.
|
||||
*/
|
||||
static async acquireWithRetriesOrExit(
|
||||
path: string,
|
||||
processName: string,
|
||||
unlockOnExit: boolean = true
|
||||
): Promise<Lockfile> {
|
||||
const startMs = Date.now()
|
||||
let lockfile
|
||||
while (Date.now() - startMs < MAX_RETRY_MS) {
|
||||
lockfile = await Lockfile.tryAcquire(path, unlockOnExit)
|
||||
if (lockfile !== undefined) break
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
|
||||
}
|
||||
if (lockfile === undefined) {
|
||||
Log.error(
|
||||
`Unable to acquire lock at ${cyan(path)}, is another instance of ${cyan(processName)} running?`
|
||||
)
|
||||
Log.info(
|
||||
`${bold('Suggestion:')} If you intended to restart ${cyan(processName)}, terminate the other process, and then try again.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
return lockfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the lockfile and closes the file descriptor.
|
||||
*
|
||||
* If this is not called, the lock will be released by the operating system
|
||||
* when the file handle is closed during process exit.
|
||||
*/
|
||||
async unlock(): Promise<void> {
|
||||
const { nativeLockfile, listener } = this
|
||||
if (nativeLockfile !== undefined) {
|
||||
await this.bindings.lockfileUnlock(nativeLockfile)
|
||||
}
|
||||
if (listener !== undefined) {
|
||||
process.off('exit', listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A blocking version of `unlock`.
|
||||
*/
|
||||
unlockSync(): void {
|
||||
const { nativeLockfile, listener } = this
|
||||
if (nativeLockfile !== undefined) {
|
||||
this.bindings.lockfileUnlockSync(nativeLockfile)
|
||||
}
|
||||
if (listener !== undefined) {
|
||||
process.off('exit', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-6
@@ -34,18 +34,30 @@ export declare class ExternalObject<T> {
|
||||
[K: symbol]: T
|
||||
}
|
||||
}
|
||||
export interface TransformOutput {
|
||||
code: string
|
||||
map?: string
|
||||
output?: string
|
||||
diagnostics: Array<string>
|
||||
}
|
||||
export declare function lockfileTryAcquireSync(
|
||||
path: string
|
||||
): { __napiType: 'Lockfile' } | null
|
||||
export declare function lockfileTryAcquire(
|
||||
path: string
|
||||
): Promise<{ __napiType: 'Lockfile' } | null>
|
||||
export declare function lockfileUnlockSync(lockfile: {
|
||||
__napiType: 'Lockfile'
|
||||
}): void
|
||||
export declare function lockfileUnlock(lockfile: {
|
||||
__napiType: 'Lockfile'
|
||||
}): Promise<void>
|
||||
export declare function mdxCompile(
|
||||
value: string,
|
||||
option: Buffer,
|
||||
signal?: AbortSignal | undefined | null
|
||||
): Promise<unknown>
|
||||
export declare function mdxCompileSync(value: string, option: Buffer): string
|
||||
export interface TransformOutput {
|
||||
code: string
|
||||
map?: string
|
||||
output?: string
|
||||
diagnostics: Array<string>
|
||||
}
|
||||
export declare function minify(
|
||||
input: Buffer,
|
||||
opts: Buffer,
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
DefineEnv,
|
||||
Endpoint,
|
||||
HmrIdentifiers,
|
||||
Lockfile,
|
||||
Project,
|
||||
ProjectOptions,
|
||||
RawEntrypoints,
|
||||
@@ -184,15 +185,15 @@ export const lockfilePatchPromise: { cur?: Promise<void> } = {}
|
||||
export async function loadBindings(
|
||||
useWasmBinary: boolean = false
|
||||
): Promise<Binding> {
|
||||
if (pendingBindings) {
|
||||
return pendingBindings
|
||||
}
|
||||
|
||||
// Increase Rust stack size as some npm packages being compiled need more than the default.
|
||||
if (!process.env.RUST_MIN_STACK) {
|
||||
process.env.RUST_MIN_STACK = '8388608'
|
||||
}
|
||||
|
||||
if (pendingBindings) {
|
||||
return pendingBindings
|
||||
}
|
||||
|
||||
if (process.env.NEXT_TEST_WASM) {
|
||||
useWasmBinary = true
|
||||
}
|
||||
@@ -1283,6 +1284,24 @@ async function loadWasm(importPath = '') {
|
||||
imports
|
||||
)
|
||||
},
|
||||
lockfileTryAcquire(_filePath: string) {
|
||||
throw new Error(
|
||||
'`lockfileTryAcquire` is not supported by the wasm bindings.'
|
||||
)
|
||||
},
|
||||
lockfileTryAcquireSync(_filePath: string) {
|
||||
throw new Error(
|
||||
'`lockfileTryAcquireSync` is not supported by the wasm bindings.'
|
||||
)
|
||||
},
|
||||
lockfileUnlock(_lockfile: Lockfile) {
|
||||
throw new Error('`lockfileUnlock` is not supported by the wasm bindings.')
|
||||
},
|
||||
lockfileUnlockSync(_lockfile: Lockfile) {
|
||||
throw new Error(
|
||||
'`lockfileUnlockSync` is not supported by the wasm bindings.'
|
||||
)
|
||||
},
|
||||
}
|
||||
return wasmBindings
|
||||
}
|
||||
@@ -1485,6 +1504,18 @@ function loadNative(importPath?: string) {
|
||||
imports
|
||||
)
|
||||
},
|
||||
lockfileTryAcquire(filePath: string) {
|
||||
return bindings.lockfileTryAcquire(filePath)
|
||||
},
|
||||
lockfileTryAcquireSync(filePath: string) {
|
||||
return bindings.lockfileTryAcquireSync(filePath)
|
||||
},
|
||||
lockfileUnlock(lockfile: Lockfile) {
|
||||
return bindings.lockfileUnlock(lockfile)
|
||||
},
|
||||
lockfileUnlockSync(lockfile: Lockfile) {
|
||||
return bindings.lockfileUnlockSync(lockfile)
|
||||
},
|
||||
}
|
||||
return nativeBindings
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
|
||||
export type { NapiTurboEngineOptions as TurboEngineOptions }
|
||||
|
||||
export type Lockfile = { __napiType: 'Lockfile' }
|
||||
|
||||
export interface Binding {
|
||||
isWasm: boolean
|
||||
turbo: {
|
||||
@@ -63,6 +65,11 @@ export interface Binding {
|
||||
injections: Record<string, string>,
|
||||
imports: Record<string, string | null>
|
||||
): string
|
||||
|
||||
lockfileTryAcquire(path: string): Promise<Lockfile | null>
|
||||
lockfileTryAcquireSync(path: string): Lockfile | null
|
||||
lockfileUnlock(lockfile: Lockfile): Promise<void>
|
||||
lockfileUnlockSync(lockfile: Lockfile): void
|
||||
}
|
||||
|
||||
export type StyledString =
|
||||
|
||||
@@ -344,7 +344,9 @@ export const experimentalSchema = {
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
lockDistDir: z.boolean().optional(),
|
||||
}
|
||||
|
||||
export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
|
||||
z.strictObject({
|
||||
allowedDevOrigins: z.array(z.string()).optional(),
|
||||
|
||||
@@ -491,6 +491,13 @@ export interface ExperimentalConfig {
|
||||
*/
|
||||
turbopackUseBuiltinSass?: boolean
|
||||
|
||||
/**
|
||||
* The module ID strategy to use for Turbopack.
|
||||
* If not set, the default is `'named'` for development and `'deterministic'`
|
||||
* for production.
|
||||
*/
|
||||
turbopackModuleIds?: 'named' | 'deterministic'
|
||||
|
||||
/**
|
||||
* For use with `@next/mdx`. Compile MDX files using the new Rust compiler.
|
||||
* @see https://nextjs.org/docs/app/api-reference/next-config-js/mdxRs
|
||||
@@ -800,11 +807,17 @@ export interface ExperimentalConfig {
|
||||
mcpServer?: boolean
|
||||
|
||||
/**
|
||||
* The module ID strategy to use for Turbopack.
|
||||
* If not set, the default is `'named'` for development and `'deterministic'`
|
||||
* for production.
|
||||
* Acquires a lockfile at `<distDir>/lock` when starting `next dev` or `next
|
||||
* build`. Failing to acquire the lock causes the process to exit with an
|
||||
* error message.
|
||||
*
|
||||
* This is because if multiple processes write to the same `distDir` at the
|
||||
* same time, it can mangle the state of the directory. Disabling this option
|
||||
* is not recommended.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
turbopackModuleIds?: 'named' | 'deterministic'
|
||||
lockDistDir?: boolean
|
||||
}
|
||||
|
||||
export type ExportPathMap = {
|
||||
@@ -1512,6 +1525,7 @@ export const defaultConfig = Object.freeze({
|
||||
browserDebugInfoInTerminal: false,
|
||||
isolatedDevBuild:
|
||||
process.env.__NEXT_EXPERIMENTAL_ISOLATED_DEV_BUILD === 'true',
|
||||
lockDistDir: true,
|
||||
},
|
||||
htmlLimitedBots: undefined,
|
||||
bundlePagesRouterDependencies: false,
|
||||
|
||||
@@ -636,7 +636,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
|
||||
.traceAsyncFn(() =>
|
||||
recursiveDeleteSyncWithAsyncRetries(
|
||||
join(this.dir, this.config.distDir),
|
||||
/^cache/
|
||||
/^(cache|lock)/
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import './node-polyfill-crypto'
|
||||
import type { default as NextNodeServer } from './next-server'
|
||||
import * as log from '../build/output/log'
|
||||
import loadConfig from './config'
|
||||
import path from 'path'
|
||||
import path from 'node:path'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { NON_STANDARD_NODE_ENV } from '../lib/constants'
|
||||
import {
|
||||
PHASE_DEVELOPMENT_SERVER,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
RouterServerContextSymbol,
|
||||
routerServerGlobal,
|
||||
} from './lib/router-utils/router-server-context'
|
||||
import { Lockfile } from '../build/lockfile'
|
||||
|
||||
let ServerImpl: typeof NextNodeServer
|
||||
|
||||
@@ -129,6 +131,7 @@ export class NextServer implements NextWrapperServer {
|
||||
private reqHandler?: NodeRequestHandler
|
||||
private reqHandlerPromise?: Promise<NodeRequestHandler>
|
||||
private preparedAssetPrefix?: string
|
||||
private lockfile?: Lockfile
|
||||
|
||||
public options: NextServerOptions
|
||||
|
||||
@@ -258,6 +261,9 @@ export class NextServer implements NextWrapperServer {
|
||||
if (this.server) {
|
||||
await this.server.close()
|
||||
}
|
||||
if (this.lockfile !== undefined) {
|
||||
await this.lockfile.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private async createServer(
|
||||
@@ -317,7 +323,22 @@ export class NextServer implements NextWrapperServer {
|
||||
private async getServer() {
|
||||
if (!this.serverPromise) {
|
||||
this.serverPromise = this[SYMBOL_LOAD_CONFIG]().then(async (conf) => {
|
||||
if (!this.options.dev) {
|
||||
if (this.options.dev) {
|
||||
if (conf.experimental.lockDistDir) {
|
||||
const dir = path.resolve(
|
||||
/* turbopackIgnore: true */ this.options.dir || '.'
|
||||
)
|
||||
const distDir = path.join(
|
||||
/* turbopackIgnore: true */ dir,
|
||||
conf.distDir
|
||||
)
|
||||
mkdirSync(distDir, { recursive: true })
|
||||
this.lockfile = await Lockfile.acquireWithRetriesOrExit(
|
||||
path.join(/* turbopackIgnore: true */ distDir, 'lock'),
|
||||
'next dev'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (conf.output === 'standalone') {
|
||||
if (!process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
|
||||
log.warn(
|
||||
|
||||
+2
-2
@@ -689,7 +689,7 @@ ${ENDGROUP}`)
|
||||
|
||||
// we only restrict 1 test per directory for
|
||||
// legacy integration tests
|
||||
if (test.file.startsWith('test/integration') && dirSema === undefined) {
|
||||
if (/^test[/\\]integration/.test(test.file) && dirSema === undefined) {
|
||||
directorySemas.set(dirName, (dirSema = new Sema(1)))
|
||||
}
|
||||
if (dirSema) await dirSema.acquire()
|
||||
@@ -734,7 +734,7 @@ ${ENDGROUP}`)
|
||||
// if test is nested in a test folder traverse up a dir to ensure
|
||||
// we clean up relevant test files
|
||||
if (testDir.endsWith('/test') || testDir.endsWith('\\test')) {
|
||||
testDir = path.join(testDir, '../')
|
||||
testDir = path.join(testDir, '..')
|
||||
}
|
||||
console.log('Cleaning test files at', testDir)
|
||||
await exec(`git clean -fdx "${testDir}"`)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Root({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <p>Page</p>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { nextTestSetup } from 'e2e-utils'
|
||||
import execa from 'execa'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
describe('lockfile', () => {
|
||||
const { next, isTurbopack } = nextTestSetup({
|
||||
files: __dirname,
|
||||
})
|
||||
|
||||
it('only allows a single instance of `next dev` to run at a time', async () => {
|
||||
const browser = await next.browser('/')
|
||||
expect(await browser.elementByCss('p').text()).toBe('Page')
|
||||
|
||||
const { stdout, stderr, exitCode } = await execa(
|
||||
'pnpm',
|
||||
['next', 'dev', isTurbopack ? '--turbopack' : '--webpack'],
|
||||
{
|
||||
cwd: next.testDir,
|
||||
env: next.env as NodeJS.ProcessEnv,
|
||||
reject: false,
|
||||
}
|
||||
)
|
||||
expect(stripAnsi(stdout + stderr)).toContain('Unable to acquire lock')
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user