@@ -41,6 +41,9 @@ class Site_Backup_Restore {
4141 private $ dash_error_type = 'unknown ' ;
4242 private $ dash_error_code = 0 ;
4343
44+ // Global backup lock handle for serializing backups
45+ private $ global_backup_lock_handle = null ;
46+
4447 public function __construct () {
4548 $ this ->fs = new Filesystem ();
4649 }
@@ -97,6 +100,12 @@ public function backup( $args, $assoc_args = [] ) {
97100 register_shutdown_function ( [ $ this , 'dash_shutdown_handler ' ] );
98101 }
99102
103+ // Acquire global lock to serialize backups (prevents OOM from concurrent backups)
104+ $ this ->acquire_global_backup_lock ();
105+
106+ // Register shutdown handler to release lock on any exit (error, crash, etc.)
107+ register_shutdown_function ( [ $ this , 'release_global_backup_lock ' ] );
108+
100109 $ this ->pre_backup_check ();
101110 $ backup_dir = EE_BACKUP_DIR . '/ ' . $ this ->site_data ['site_url ' ];
102111
@@ -146,6 +155,9 @@ public function backup( $args, $assoc_args = [] ) {
146155 }
147156 }
148157
158+ // Release global backup lock (also released by shutdown handler as safety net)
159+ $ this ->release_global_backup_lock ();
160+
149161 delem_log ( 'site backup end ' );
150162 }
151163
@@ -419,7 +431,13 @@ private function maybe_backup_custom_docker_compose( $backup_dir ) {
419431 if ( $ this ->fs ->exists ( $ custom_docker_compose_dir ) ) {
420432 $ custom_docker_compose_dir_archive = $ backup_dir . '/user-docker-compose.zip ' ;
421433 $ archive_command = sprintf ( 'cd %s && 7z a -mx=1 %s . ' , $ custom_docker_compose_dir , $ custom_docker_compose_dir_archive );
422- EE ::exec ( $ archive_command );
434+ $ result = EE ::launch ( $ archive_command );
435+
436+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
437+ // This is optional, so we just log a warning instead of failing
438+ if ( $ result ->return_code >= 2 ) {
439+ EE ::warning ( 'Failed to backup custom docker-compose directory. Continuing with backup. ' );
440+ }
423441 }
424442 }
425443
@@ -431,10 +449,11 @@ private function backup_site_dir( $backup_dir ) {
431449 $ backup_file = $ backup_dir . '/ ' . $ this ->site_data ['site_url ' ] . '.zip ' ;
432450 $ backup_command = sprintf ( 'cd %s && 7z a -mx=1 %s . ' , $ site_dir , $ backup_file );
433451
434- $ result = EE ::exec ( $ backup_command );
452+ $ result = EE ::launch ( $ backup_command );
435453
436- // Check if archive was created successfully
437- if ( ! $ result || ! $ this ->fs ->exists ( $ backup_file ) ) {
454+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
455+ // Exit code 1 means warnings (e.g., missing symlink targets) but archive is still created
456+ if ( $ result ->return_code >= 2 || ! $ this ->fs ->exists ( $ backup_file ) ) {
438457 $ this ->capture_error (
439458 'Failed to create site backup archive ' ,
440459 self ::ERROR_TYPE_FILESYSTEM ,
@@ -470,9 +489,10 @@ private function backup_wp_content_dir( $backup_dir ) {
470489 }
471490
472491 $ backup_command = sprintf ( 'cd %s && 7z a -mx=1 %s wp-config.php ' , $ site_dir . '/../ ' , $ backup_file );
473- $ result = EE ::exec ( $ backup_command );
492+ $ result = EE ::launch ( $ backup_command );
474493
475- if ( ! $ result ) {
494+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
495+ if ( $ result ->return_code >= 2 || ! $ this ->fs ->exists ( $ backup_file ) ) {
476496 $ this ->capture_error (
477497 'Failed to create WordPress content backup archive ' ,
478498 self ::ERROR_TYPE_FILESYSTEM ,
@@ -486,15 +506,34 @@ private function backup_wp_content_dir( $backup_dir ) {
486506
487507 // Include meta.json in the zip archive (Corrected logic)
488508 $ backup_command = sprintf ( 'cd %s && 7z u -snl -mx=1 %s %s wp-content ' , $ site_dir , $ backup_file , $ meta_file );
489- EE ::exec ( $ backup_command );
509+ $ result = EE ::launch ( $ backup_command );
490510 // Remove the file
491511 $ this ->fs ->remove ( $ meta_file );
492512
513+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
514+ if ( $ result ->return_code >= 2 || ! $ this ->fs ->exists ( $ backup_file ) ) {
515+ $ this ->capture_error (
516+ 'Failed to create WordPress content backup archive ' ,
517+ self ::ERROR_TYPE_FILESYSTEM ,
518+ 3002
519+ );
520+ EE ::error ( 'Failed to create backup archive. Please check disk space and file permissions. ' );
521+ }
493522
494523 $ uploads_dir = $ site_dir . '/wp-content/uploads ' ;
495524 if ( is_link ( $ uploads_dir ) ) {
496525 $ backup_command = sprintf ( 'cd %s && 7z u -mx=1 %s wp-content/uploads ' , $ site_dir , $ backup_file );
497- EE ::exec ( $ backup_command );
526+ $ result = EE ::launch ( $ backup_command );
527+
528+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
529+ if ( $ result ->return_code >= 2 ) {
530+ $ this ->capture_error (
531+ 'Failed to create WordPress content backup archive ' ,
532+ self ::ERROR_TYPE_FILESYSTEM ,
533+ 3002
534+ );
535+ EE ::error ( 'Failed to create backup archive. Please check disk space and file permissions. ' );
536+ }
498537 }
499538
500539 // Final check that backup file was created successfully
@@ -517,9 +556,10 @@ private function backup_nginx_conf( $backup_dir ) {
517556 $ backup_file = $ backup_dir . '/conf.zip ' ;
518557 $ backup_command = sprintf ( 'cd %s && 7z a -mx=1 %s nginx ' , $ conf_dir , $ backup_file );
519558
520- $ result = EE ::exec ( $ backup_command );
559+ $ result = EE ::launch ( $ backup_command );
521560
522- if ( ! $ result ) {
561+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
562+ if ( $ result ->return_code >= 2 || ! $ this ->fs ->exists ( $ backup_file ) ) {
523563 $ this ->capture_error (
524564 'Failed to create nginx configuration backup archive ' ,
525565 self ::ERROR_TYPE_FILESYSTEM ,
@@ -536,9 +576,10 @@ private function backup_php_conf( $backup_dir ) {
536576 $ backup_file = $ backup_dir . '/conf.zip ' ;
537577 $ backup_command = sprintf ( 'cd %s && 7z u -mx=1 %s php ' , $ conf_dir , $ backup_file );
538578
539- $ result = EE ::exec ( $ backup_command );
579+ $ result = EE ::launch ( $ backup_command );
540580
541- if ( ! $ result ) {
581+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
582+ if ( $ result ->return_code >= 2 || ! $ this ->fs ->exists ( $ backup_file ) ) {
542583 $ this ->capture_error (
543584 'Failed to create PHP configuration backup archive ' ,
544585 self ::ERROR_TYPE_FILESYSTEM ,
@@ -610,15 +651,18 @@ private function backup_db( $backup_dir ) {
610651 EE ::exec ( sprintf ( 'mv %s %s ' , $ sql_dump_path , $ sql_file ) );
611652 $ backup_command = sprintf ( 'cd %s && 7z u -mx=1 %s sql ' , $ backup_dir , $ backup_file );
612653
613- $ result = EE ::exec ( $ backup_command );
614- if ( ! $ result ) {
654+ $ result = EE ::launch ( $ backup_command );
655+
656+ // 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
657+ if ( $ result ->return_code >= 2 || ! $ this ->fs ->exists ( $ backup_file ) ) {
615658 $ this ->capture_error (
616659 'Failed to compress database backup into archive ' ,
617660 self ::ERROR_TYPE_FILESYSTEM ,
618661 3002
619662 );
620663 EE ::error ( 'Failed to compress database backup. Please check disk space. ' );
621664 }
665+
622666 $ this ->fs ->remove ( $ backup_dir . '/sql ' );
623667 }
624668
@@ -960,7 +1004,7 @@ private function pre_restore_check() {
9601004 $ this ->pre_backup_restore_checks ();
9611005
9621006 $ remote_path = $ this ->get_remote_path ( false );
963- $ command = sprintf ( 'rclone size --json %s ' , $ remote_path );
1007+ $ command = sprintf ( 'rclone size --json %s ' , escapeshellarg ( $ remote_path ) );
9641008 $ output = EE ::launch ( $ command );
9651009
9661010 if ( $ output ->return_code ) {
@@ -1137,7 +1181,7 @@ private function list_remote_backups( $return = false ) {
11371181
11381182 $ remote_path = $ this ->get_rclone_config_path (); // Get remote path without creating a new timestamped folder
11391183
1140- $ command = sprintf ( 'rclone lsf --dirs-only %s ' , $ remote_path ); // List only directories
1184+ $ command = sprintf ( 'rclone lsf --dirs-only %s ' , escapeshellarg ( $ remote_path ) ); // List only directories
11411185 $ output = EE ::launch ( $ command );
11421186
11431187 if ( $ output ->return_code !== 0 && ! $ return ) {
@@ -1216,7 +1260,7 @@ private function get_remote_path( $upload = true ) {
12161260 private function rclone_download ( $ path ) {
12171261 $ cpu_cores = intval ( EE ::launch ( 'nproc ' )->stdout );
12181262 $ multi_threads = min ( intval ( $ cpu_cores ) * 2 , 32 );
1219- $ command = sprintf ( "rclone copy -P --multi-thread-streams %d %s %s " , $ multi_threads , $ this ->get_remote_path ( false ), $ path );
1263+ $ command = sprintf ( "rclone copy -P --multi-thread-streams %d %s %s " , $ multi_threads , escapeshellarg ( $ this ->get_remote_path ( false ) ), escapeshellarg ( $ path ) );
12201264 $ output = EE ::launch ( $ command );
12211265
12221266 if ( $ output ->return_code ) {
@@ -1245,7 +1289,7 @@ private function rclone_upload( $path ) {
12451289 $ s3_flag = ' --s3-chunk-size=64M --s3-upload-concurrency ' . min ( intval ( $ cpu_cores ) * 2 , 32 );
12461290 }
12471291
1248- $ command = sprintf ( "rclone copy -P %s --transfers %d --checkers %d --buffer-size %s %s %s " , $ s3_flag , $ transfers , $ transfers , $ buffer_size , $ path, $ this ->get_remote_path () );
1292+ $ command = sprintf ( "rclone copy -P %s --transfers %d --checkers %d --buffer-size %s %s %s " , $ s3_flag , $ transfers , $ transfers , $ buffer_size , escapeshellarg ( $ path ), escapeshellarg ( $ this ->get_remote_path () ) );
12491293 $ output = EE ::launch ( $ command );
12501294
12511295 if ( $ output ->return_code ) {
@@ -1257,7 +1301,7 @@ private function rclone_upload( $path ) {
12571301 EE ::error ( 'Error uploading backup to remote storage. ' );
12581302 } else {
12591303
1260- $ command = sprintf ( 'rclone lsf %s ' , $ this ->get_remote_path ( false ) );
1304+ $ command = sprintf ( 'rclone lsf %s ' , escapeshellarg ( $ this ->get_remote_path ( false ) ) );
12611305 $ output = EE ::launch ( $ command );
12621306 $ remote_path = $ output ->stdout ;
12631307 EE ::success ( 'Backup uploaded to remote storage. Remote path: ' . $ remote_path );
@@ -1571,4 +1615,87 @@ private function sanitize_count( $value ) {
15711615
15721616 return intval ( $ value );
15731617 }
1618+
1619+ /**
1620+ * Acquire a global backup lock to ensure only one backup runs at a time.
1621+ * Uses flock() for atomic, race-condition-free locking.
1622+ *
1623+ * This prevents multiple concurrent backups from exhausting system resources
1624+ * (RAM, CPU, disk I/O, network bandwidth) when triggered simultaneously.
1625+ *
1626+ * Note: flock() may not work reliably on NFS or other network filesystems.
1627+ * EE_BACKUP_DIR should be on a local filesystem for proper lock behavior.
1628+ *
1629+ * @return void
1630+ */
1631+ private function acquire_global_backup_lock () {
1632+ $ lock_file = EE_BACKUP_DIR . '/backup-global.lock ' ;
1633+ $ max_wait = 86400 ; // 24 hours max wait
1634+ $ waited = 0 ;
1635+ $ interval = 60 ; // Check every 60 seconds
1636+
1637+ // Ensure backup directory exists
1638+ if ( ! $ this ->fs ->exists ( EE_BACKUP_DIR ) ) {
1639+ $ this ->fs ->mkdir ( EE_BACKUP_DIR );
1640+ }
1641+
1642+ // Open file handle (creates if doesn't exist)
1643+ $ this ->global_backup_lock_handle = fopen ( $ lock_file , 'c+ ' );
1644+
1645+ if ( ! $ this ->global_backup_lock_handle ) {
1646+ $ this ->capture_error (
1647+ 'Cannot create backup lock file ' ,
1648+ self ::ERROR_TYPE_FILESYSTEM ,
1649+ 5002
1650+ );
1651+ EE ::error ( 'Cannot create backup lock file. ' );
1652+ }
1653+
1654+ // Try to acquire exclusive lock (non-blocking first to log status)
1655+ while ( ! flock ( $ this ->global_backup_lock_handle , LOCK_EX | LOCK_NB ) ) {
1656+ if ( $ waited >= $ max_wait ) {
1657+ fclose ( $ this ->global_backup_lock_handle );
1658+ $ this ->global_backup_lock_handle = null ;
1659+ $ this ->capture_error (
1660+ 'Timeout waiting for another backup to complete ' ,
1661+ self ::ERROR_TYPE_LOCK ,
1662+ 5003
1663+ );
1664+ EE ::error ( 'Timeout waiting for another backup. Try again later. ' );
1665+ }
1666+
1667+ // Read who has the lock
1668+ rewind ( $ this ->global_backup_lock_handle );
1669+ $ lock_info = stream_get_contents ( $ this ->global_backup_lock_handle );
1670+
1671+ EE ::log ( sprintf ( 'Another backup in progress (%s). Waiting... (%d/%d sec) ' ,
1672+ trim ( $ lock_info ) ?: 'unknown ' , $ waited , $ max_wait ) );
1673+
1674+ sleep ( $ interval );
1675+ $ waited += $ interval ;
1676+ }
1677+
1678+ // Got the lock! Write our info
1679+ ftruncate ( $ this ->global_backup_lock_handle , 0 );
1680+ rewind ( $ this ->global_backup_lock_handle );
1681+ fwrite ( $ this ->global_backup_lock_handle , $ this ->site_data ['site_url ' ] . ' (PID: ' . getmypid () . ') ' );
1682+ fflush ( $ this ->global_backup_lock_handle );
1683+
1684+ EE ::debug ( 'Acquired global backup lock for: ' . $ this ->site_data ['site_url ' ] );
1685+ }
1686+
1687+ /**
1688+ * Release the global backup lock.
1689+ * Safe to call multiple times (idempotent).
1690+ *
1691+ * @return void
1692+ */
1693+ public function release_global_backup_lock () {
1694+ if ( $ this ->global_backup_lock_handle ) {
1695+ flock ( $ this ->global_backup_lock_handle , LOCK_UN );
1696+ fclose ( $ this ->global_backup_lock_handle );
1697+ $ this ->global_backup_lock_handle = null ;
1698+ EE ::debug ( 'Released global backup lock ' );
1699+ }
1700+ }
15741701}
0 commit comments