Skip to content

fix(backup): make the per-site backup/restore lock crash-safe (flock) - #480

Open
mrrobot47 wants to merge 4 commits into
EasyEngine:developfrom
mrrobot47:fix/backup-lock-robustness
Open

mrrobot47 wants to merge 4 commits into
EasyEngine:developfrom
mrrobot47:fix/backup-lock-robustness

Conversation

@mrrobot47

@mrrobot47 mrrobot47 commented Jun 29, 2026 •

Copy link
Copy Markdown
Member

Summary

The per-site backup/restore lock (<site>.lock) was a file-existence lock removed only on success (and one disk-space) path. Any crash/OOM/error exit left a stale lock that permanently blocked every future backup AND restore of that site ("Another backup/restore process is running") until an operator deleted it by hand. The fs->exists()-then-fs->dumpFile() was also a same-site backup↔restore TOCTOU race.

Fix

  • Convert the per-site lock to flock() on a handle opened c+e (O_CLOEXEC), acquired non-blocking in pre_backup_restore_checks() and released by an idempotent release_site_backup_lock() on the success paths + a register_shutdown_function. flock is released by the OS on process death, so the lock can no longer go stale; this also makes the lock atomic (fixes the TOCTOU).
  • Add the e (O_CLOEXEC) flag to the global lock fd too, so backup subprocesses (rclone/mysqldump/docker exec) don't inherit the descriptor and keep the lock held after the parent dies.
  • Stop EE_Site_Command::shut_down_function() from deleting the lock file — unlinking a file another process holds an flock on lets a later process re-lock a fresh inode at the same path and silently break mutual exclusion.

Verification

  • ee site restore that fails (e.g. a site-type mismatch), a kill -9 of a running ee site backup, and a leftover old-format lock file (lock) no longer block the next backup/restore of that site; the old file is taken over and rewritten.
  • Mutual exclusion still holds: a restore started during a backup of the same site is refused, and ee site info during a backup no longer deletes the running backup's lock file (it did before, which let a concurrent restore through).
  • The per-site lock is now held until after the EasyDash callback and the old-backup cleanup, so a restore can't pick a backup that the dash rollback or retention is about to purge.
  • O_CLOEXEC matters when the PHP process is hard-killed while a child (7z, rclone, mysqldump) is still running: without it the orphan keeps the lock until it exits; with it the lock is free immediately. On a normal exit both behave the same, because the explicit LOCK_UN releases the shared lock.
  • Lock files now persist by design (never unlinked; tiny), like backup-global.lock.

Tested on Ubuntu 26.04 with EasyEngine 4.12.0 and a real rclone remote (WordPress and PHP sites, including a kill -9 of the whole process group mid-backup).

Out of scope (noted)

restore() does not acquire the global lock (pre-existing). Left as a separate, deliberate behavior decision.

The per-site lock was a file-existence lock (fs->exists check + fs->dumpFile create), removed only on success and one disk-space error path. Any other exit -- archive/DB/upload failure, restore mismatch, OOM/SIGKILL, Ctrl-C -- left a stale <site>.lock that permanently blocked every future backup AND restore of that site with 'Another backup/restore process is running' until an operator deleted it by hand. The check-then-create was also a TOCTOU race between same-site backup and restore.

Convert it to an flock() lock on a handle opened with the 'e' (O_CLOEXEC) flag, acquired non-blocking in pre_backup_restore_checks() and released by an idempotent release_site_backup_lock() on the success paths and via register_shutdown_function. flock is released by the OS on process death (verified for SIGKILL), so the lock can no longer go stale; O_CLOEXEC stops backup subprocesses (rclone/mysqldump/docker exec) from inheriting the descriptor and holding it after the parent exits (verified: c+ stays held, c+e releases). The global lock fd gains the same 'e' flag.

Also remove the lock-file deletion from EE_Site_Command::shut_down_function(): with flock, unlinking a held lock file lets a later process re-lock a fresh inode at the same path and break mutual exclusion.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes backup/restore locking crash-safe by switching the per-site <site>.lock from a file-existence sentinel to an OS-managed flock() held on an open file handle, preventing stale locks after crashes and removing a same-site TOCTOU race between backup and restore.

Changes:

  • Add a per-site flock() lock handle acquired non-blocking during pre_backup_restore_checks(), and release it via an idempotent release_site_backup_lock() plus a shutdown handler.
  • Use fopen(..., 'c+e') (CLOEXEC) for both per-site and global lock handles to prevent subprocess FD inheritance from prolonging locks.
  • Remove shutdown-time unlinking of the per-site lock file to avoid breaking mutual exclusion semantics with flock().

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/helper/Site_Backup_Restore.php Replaces per-site lock sentinel with flock() on a CLOEXEC handle and adds an idempotent release method; also adds CLOEXEC to the global lock handle.
src/helper/class-ee-site.php Stops deleting the per-site lock file during shutdown to preserve correct flock() mutual exclusion behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/helper/class-ee-site.php Outdated
Comment on lines +2208 to +2212
// The per-site backup/restore lock is now an flock() held by
// Site_Backup_Restore and released automatically on process exit. It must
// NOT be deleted here: unlinking a file that another process currently
// holds an flock on lets a later process create a fresh inode at the same
// path and acquire its own lock, silently breaking mutual exclusion.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 7a2404a (reworded to "a flock").

"a flock" reads correctly (flock is pronounced with a consonant sound); addresses a review comment on the lock-robustness change.
Shorten the multi-line lock comments to the non-obvious constraint (flock + O_CLOEXEC, never unlink the lock file) and drop the claim that SIGTERM skips the shutdown handler: site commands install a pcntl SIGTERM handler (rollback(), which exits), so SIGTERM doesn't take the default kill path.
… cleanup

With --dash-auth, backup() released the per-site lock before the EasyDash success callback, cleanup_old_backups() and rollback_failed_backup(). A same-site restore could then start in that window and download the just-uploaded backup while rollback_failed_backup() purges it, or a --id backup that cleanup_old_backups() is purging. Release the per-site lock together with the global lock, after those remote purges finish. Non-dash backups already clean up inside rclone_upload(), under the lock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants