diff --git a/src/helper/Site_Backup_Restore.php b/src/helper/Site_Backup_Restore.php index a287b639..4df44107 100644 --- a/src/helper/Site_Backup_Restore.php +++ b/src/helper/Site_Backup_Restore.php @@ -51,6 +51,9 @@ class Site_Backup_Restore { // Global backup lock handle for serializing backups private $global_backup_lock_handle = null; + // Per-site backup/restore lock handle (flock-based, auto-released on exit) + private $site_backup_lock_handle = null; + public function __construct() { $this->fs = new Filesystem(); } @@ -152,8 +155,6 @@ public function backup( $args, $assoc_args = [] ) { $this->rclone_upload( $backup_dir ); $this->fs->remove( $backup_dir ); - $this->fs->remove( EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock' ); - // Mark backup as completed and send success callback $this->dash_backup_completed = true; if ( $this->dash_auth_enabled ) { @@ -173,6 +174,9 @@ public function backup( $args, $assoc_args = [] ) { } } + // Held until here so a same-site restore can't race the remote purges above. + $this->release_site_backup_lock(); + // Release global backup lock (also released by shutdown handler as safety net) $this->release_global_backup_lock(); @@ -300,7 +304,7 @@ public function restore( $args, $assoc_args = [] ) { EE::log( 'Reloading site.' ); EE::run_command( [ 'site', 'reload', $this->site_data['site_url'] ], [], [] ); - $this->fs->remove( EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock' ); + $this->release_site_backup_lock(); EE::success( 'Site restored successfully.' ); @@ -946,16 +950,38 @@ private function pre_backup_restore_checks() { $lock_file = EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock'; - if ( $this->fs->exists( $lock_file ) ) { + // flock so the OS drops the lock if we die; 'e' (O_CLOEXEC) so orphaned subprocesses can't keep holding it. + $this->site_backup_lock_handle = fopen( $lock_file, 'c+e' ); + + if ( ! $this->site_backup_lock_handle ) { + $this->capture_error( + 'Cannot create backup lock file', + self::ERROR_TYPE_FILESYSTEM, + 5002 + ); + EE::error( 'Cannot create backup lock file.' ); + } + + // Non-blocking: fail fast if another backup/restore holds this site's lock. + if ( ! flock( $this->site_backup_lock_handle, LOCK_EX | LOCK_NB ) ) { + fclose( $this->site_backup_lock_handle ); + $this->site_backup_lock_handle = null; $this->capture_error( 'Another backup/restore process is already running for this site', self::ERROR_TYPE_LOCK, 2003 ); EE::error( 'Another backup/restore process is running. Please wait for it to complete.' ); - } else { - $this->fs->dumpFile( $lock_file, 'lock' ); } + + // Hard kills skip shutdown functions, but the OS still drops the flock. + register_shutdown_function( [ $this, 'release_site_backup_lock' ] ); + + // Record the holder for debugging only; flock is the source of truth. + ftruncate( $this->site_backup_lock_handle, 0 ); + rewind( $this->site_backup_lock_handle ); + fwrite( $this->site_backup_lock_handle, $this->site_data['site_url'] . ' (PID: ' . getmypid() . ')' ); + fflush( $this->site_backup_lock_handle ); } private function pre_backup_check() { @@ -987,7 +1013,7 @@ private function pre_backup_check() { 3001 ); - $this->fs->remove( EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock' ); + $this->release_site_backup_lock(); EE::error( $error_message ); } } @@ -1904,8 +1930,8 @@ private function acquire_global_backup_lock() { $this->fs->mkdir( EE_BACKUP_DIR ); } - // Open file handle (creates if doesn't exist) - $this->global_backup_lock_handle = fopen( $lock_file, 'c+' ); + // 'e' (O_CLOEXEC) so orphaned subprocesses can't keep holding the lock after we die. + $this->global_backup_lock_handle = fopen( $lock_file, 'c+e' ); if ( ! $this->global_backup_lock_handle ) { $this->capture_error( @@ -1963,4 +1989,19 @@ public function release_global_backup_lock() { EE::debug( 'Released global backup lock' ); } } + + /** + * Release the per-site backup/restore lock. + * Safe to call multiple times (idempotent). + * + * @return void + */ + public function release_site_backup_lock() { + if ( $this->site_backup_lock_handle ) { + flock( $this->site_backup_lock_handle, LOCK_UN ); + fclose( $this->site_backup_lock_handle ); + $this->site_backup_lock_handle = null; + EE::debug( 'Released per-site backup lock' ); + } + } } diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 08ad268e..b0dfd29d 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -2205,14 +2205,7 @@ protected function shut_down_function() { $logger = \EE::get_file_logger()->withName( 'site-command' ); $error = error_get_last(); - // Check if the $this->site_data is set and it is array and $this->site_data['site_url'] is set. - if ( isset( $this->site_data ) && is_array( $this->site_data ) && isset( $this->site_data['site_url'] ) ) { - // release lock if there. - $lock_file = EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock'; - if ( $this->fs->exists( $lock_file ) ) { - $this->fs->remove( $lock_file ); - } - } + // Never unlink the per-site backup lock here: it's a flock, and a fresh inode would let a second process lock it. if ( isset( $error ) && $error['type'] === E_ERROR ) { \EE::warning( 'An Error occurred. Initiating clean-up.' );