diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts index f4c0724545f84..1f8b083d13cde 100644 --- a/packages/nx/src/native/index.d.ts +++ b/packages/nx/src/native/index.d.ts @@ -13,6 +13,14 @@ export declare class ChildProcess { onOutput(callback: (message: string) => void): void } +export declare class FileLock { + locked: boolean + constructor(lockFilePath: string) + lock(): void + unlock(): void + wait(timeout?: number | undefined | null): Promise +} + export declare class HashPlanner { constructor(nxJson: NxJson, projectGraph: ExternalObject) getPlans(taskIds: Array, taskGraph: TaskGraph): Record diff --git a/packages/nx/src/native/native-bindings.js b/packages/nx/src/native/native-bindings.js index 26d2652231b9b..35e5ff7b3dbf8 100644 --- a/packages/nx/src/native/native-bindings.js +++ b/packages/nx/src/native/native-bindings.js @@ -362,6 +362,7 @@ if (!nativeBinding) { } module.exports.ChildProcess = nativeBinding.ChildProcess +module.exports.FileLock = nativeBinding.FileLock module.exports.HashPlanner = nativeBinding.HashPlanner module.exports.ImportResult = nativeBinding.ImportResult module.exports.NxCache = nativeBinding.NxCache diff --git a/packages/nx/src/native/utils/file_lock.rs b/packages/nx/src/native/utils/file_lock.rs new file mode 100644 index 0000000000000..97ee2526df1b8 --- /dev/null +++ b/packages/nx/src/native/utils/file_lock.rs @@ -0,0 +1,85 @@ +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::Path; +use std::time::Duration; + +#[napi] +#[derive(Clone)] +pub struct FileLock { + #[napi] + pub locked: bool, + + lock_file_path: String, +} + +#[napi] +impl FileLock { + #[napi(constructor)] + pub fn new(lock_file_path: String) -> anyhow::Result { + let locked = Path::new(&lock_file_path).exists(); + Ok(Self { + locked, + lock_file_path, + }) + } + + #[napi] + pub fn lock(&mut self) -> anyhow::Result<()> { + if self.locked { + anyhow::bail!("File {} is already locked", self.lock_file_path) + } + let mut lock_file = File::create(&self.lock_file_path)?; + lock_file.write_all(b"")?; + self.locked = true; + Ok(()) + } + + #[napi] + pub fn unlock(&mut self) -> anyhow::Result<()> { + if !self.locked { + anyhow::bail!("File {} is not locked", self.lock_file_path) + } + fs::remove_file(&self.lock_file_path).or_else(|err| { + if err.kind() == io::ErrorKind::NotFound { + Ok(()) + } else { + Err(err) + } + })?; + self.locked = false; + Ok(()) + } + + #[napi] + pub async fn wait(&self, timeout: Option) -> Result<(), napi::Error> { + if !self.locked { + return Ok(()); + } + + let start = std::time::Instant::now(); + let duration = timeout.map(|t| Duration::from_secs(u64::try_from(t).unwrap())); + + loop { + if !self.locked || !Path::new(&self.lock_file_path).exists() { + break Ok(()); + } + + if let Some(duration) = duration { + if start.elapsed() > duration { + break Err(napi::Error::from_reason("Timeout waiting for lock")); + } + } + + std::thread::sleep(Duration::from_millis(2)); + } + } +} + +// Ensure the lock file is removed when the FileLock is dropped +impl Drop for FileLock { + fn drop(&mut self) { + if self.locked { + let _ = self.unlock(); + } + } +} diff --git a/packages/nx/src/native/utils/mod.rs b/packages/nx/src/native/utils/mod.rs index 97616e92607a7..ddd46fee2fbf7 100644 --- a/packages/nx/src/native/utils/mod.rs +++ b/packages/nx/src/native/utils/mod.rs @@ -11,5 +11,6 @@ pub use normalize_trait::Normalize; #[cfg_attr(target_arch = "wasm32", path = "atomics/wasm.rs")] pub mod atomics; pub mod ci; +pub mod file_lock; pub use atomics::*; diff --git a/packages/nx/src/project-graph/project-graph.ts b/packages/nx/src/project-graph/project-graph.ts index 63691973dbc4d..3a67ede90ae7b 100644 --- a/packages/nx/src/project-graph/project-graph.ts +++ b/packages/nx/src/project-graph/project-graph.ts @@ -35,7 +35,7 @@ import { } from './utils/retrieve-workspace-files'; import { getPlugins } from './plugins/get-plugins'; import { logger } from '../utils/logger'; -import { FileLock } from '../utils/file-lock'; +import { FileLock } from '../native'; import { join } from 'path'; import { workspaceDataDirectory } from '../utils/cache-directory'; @@ -275,7 +275,18 @@ export async function createProjectGraphAndSourceMapsAsync( performance.mark('create-project-graph-async:start'); if (!daemonClient.enabled()) { - const lock = new FileLock(join(workspaceDataDirectory, 'project-graph')); + const lock = new FileLock( + join(workspaceDataDirectory, 'project-graph.lock') + ); + + function cleanupFileLock() { + try { + lock.unlock(); + } catch {} + } + + process.on('exit', cleanupFileLock); + if (lock.locked) { logger.verbose( 'Waiting for graph construction in another process to complete' @@ -316,10 +327,11 @@ export async function createProjectGraphAndSourceMapsAsync( 'create-project-graph-async:start', 'create-project-graph-async:end' ); - lock.unlock(); return res; } catch (e) { handleProjectGraphError(opts, e); + } finally { + lock.unlock(); } } else { try { diff --git a/packages/nx/src/utils/file-lock.ts b/packages/nx/src/utils/file-lock.ts deleted file mode 100644 index 2e8046cd44e22..0000000000000 --- a/packages/nx/src/utils/file-lock.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { existsSync, rmSync, watch, writeFileSync, mkdirSync } from 'fs'; -import { dirname } from 'path'; - -export class FileLock { - locked: boolean; - - private lockFilePath: string; - lockPromise: Promise; - - constructor(file: string) { - // Ensure the directory exists - mkdirSync(dirname(file), { recursive: true }); - - this.lockFilePath = `${file}.lock`; - this.locked = existsSync(this.lockFilePath); - } - - lock() { - if (this.locked) { - throw new Error(`File ${this.lockFilePath} is already locked`); - } - this.locked = true; - writeFileSync(this.lockFilePath, ''); - } - - unlock() { - if (!this.locked) { - throw new Error(`File ${this.lockFilePath} is not locked`); - } - this.locked = false; - try { - rmSync(this.lockFilePath); - } catch (err) { - if (err.code !== 'ENOENT') { - throw err; - } - } - } - - wait(timeout?: number) { - return new Promise((res, rej) => { - try { - // If the file watcher is supported, we can use it to wait for the lock file to be deleted. - let watcher = watch(this.lockFilePath); - watcher.on('change', (eventType) => { - // For whatever reason, the node file watcher can sometimes - // emit rename events instead of delete events. - if (eventType === 'delete' || eventType === 'rename') { - this.locked = false; - watcher.close(); - res(); - } - }); - } catch { - // File watching is not supported - let start = Date.now(); - let interval = setInterval(() => { - if (!this.locked || !existsSync(this.lockFilePath)) { - clearInterval(interval); - res(); - } - - const elapsed = Date.now() - start; - if (timeout && elapsed > timeout) { - rej( - new Error(`Timeout waiting for file lock ${this.lockFilePath}`) - ); - } - }, 2); - } - }); - } -}