diff --git a/deployer/assets/installer_template.html b/deployer/assets/installer_template.html index 4b8514e..9268333 100644 --- a/deployer/assets/installer_template.html +++ b/deployer/assets/installer_template.html @@ -187,6 +187,15 @@ border-radius: 99px; transition: width 0.18s linear; } + .install-progress-status { + margin: 0 0 12px; + max-width: 260px; + font-size: 12px; + line-height: 1.4; + color: var(--text-secondary, #888); + text-align: center; + min-height: 16px; + } /* ---------- setup ---------- */ .setup-wrap { @@ -391,6 +400,7 @@

+ @@ -459,6 +469,7 @@

desc: $("desc"), progressWrap: $("progressWrap"), progressBar: $("progressBar"), + progressStatus: $("progressStatus"), quick: $("quick"), custom: $("custom"), close: $("close"), @@ -499,7 +510,11 @@

els.custom.classList.toggle("hidden", on); els.desc.classList.toggle("hidden", !on); els.progressWrap.classList.toggle("hidden", !on); - if (!on) els.progressBar.style.width = "0%"; + if (!on) { + els.progressBar.style.width = "0%"; + els.progressStatus.classList.add("hidden"); + els.progressStatus.textContent = ""; + } } function showError(msg) { @@ -638,11 +653,17 @@

startTextCycle(); } els.progressBar.style.width = snap.progress + "%"; + const step = snap.progress_text || ""; + const detail = snap.progress_detail || ""; + const statusText = detail ? step + " — " + detail : step; + els.progressStatus.textContent = statusText; + els.progressStatus.classList.toggle("hidden", !statusText); } if (snap.status === "success") { stopImageCycle(); stopTextCycle(); els.progressBar.style.width = "100%"; + els.progressStatus.classList.add("hidden"); if (!state.closeTimer) { state.closeTimer = setTimeout(() => window.pywebview.api.close_window(), 2000); } diff --git a/deployer/openclaw_upgrade.py b/deployer/openclaw_upgrade.py index de77889..e9f02ac 100644 --- a/deployer/openclaw_upgrade.py +++ b/deployer/openclaw_upgrade.py @@ -9,7 +9,8 @@ import shutil import stat import uuid -from collections.abc import Iterable +from collections.abc import Callable, Iterable +from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict, dataclass, field from datetime import UTC, datetime from enum import StrEnum @@ -110,8 +111,23 @@ def _flush_and_fsync(file: Any) -> None: def _fsync_file(path: Path) -> None: - with path.open("rb+") as file: - os.fsync(file.fileno()) + try: + with path.open("rb+") as file: + os.fsync(file.fileno()) + except PermissionError: + # On Windows a file carrying the read-only attribute cannot be opened + # for writing, which os.fsync (FlushFileBuffers) requires. npm packages + # frequently ship read-only files, so temporarily clear the attribute so + # the durability flush still happens, then restore the original mode. + if os.name != "nt": + raise + original_mode = path.stat().st_mode + os.chmod(path, stat.S_IWRITE) + try: + with path.open("rb+") as file: + os.fsync(file.fileno()) + finally: + os.chmod(path, original_mode) def _directory_fsync_is_unsupported(error: OSError) -> bool: @@ -276,15 +292,61 @@ def _durable_remove(path: Path) -> None: _fsync_directory(path.parent) -def _fsync_payload_tree(root: Path) -> None: +def _make_writable_and_retry(func: Any, target: Any, _exc: Any) -> None: + """rmtree onexc handler: clear the read-only attribute, then retry once. + + npm packages ship many read-only files that otherwise make ``rmtree`` fail + on Windows with ``WinError 5`` (access denied). + """ + try: + os.chmod(target, stat.S_IWRITE) + func(target) + except OSError: + pass + + +def _force_remove_tree(path: Path) -> None: + """Best-effort recursive removal that tolerates read-only files.""" + if not path.exists() and not path.is_symlink(): + return + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path, onexc=_make_writable_and_retry) + return + try: + os.chmod(path, stat.S_IWRITE) + except OSError: + pass + path.unlink(missing_ok=True) + + +_FSYNC_MAX_WORKERS = 16 + + +def _fsync_payload_tree( + root: Path, + on_progress: Callable[[int, int], None] | None = None, +) -> None: if not root.exists(): return + files: list[Path] = [] directories = [root] for path in root.rglob("*"): if path.is_file(): - _fsync_file(path) + files.append(path) elif path.is_dir(): directories.append(path) + total = len(files) + if total: + # fsync is a blocking C call that releases the GIL, so a thread pool + # turns the per-file durability flush from a serial ~90 files/sec crawl + # (22+ min for a 36k-file npm package) into a concurrent I/O batch. + done = 0 + report_every = max(1, total // 100) + with ThreadPoolExecutor(max_workers=_FSYNC_MAX_WORKERS) as pool: + for _ in pool.map(_fsync_file, files): + done += 1 + if on_progress is not None and (done % report_every == 0 or done == total): + on_progress(done, total) for directory in sorted(directories, key=lambda path: len(path.parts), reverse=True): _fsync_directory(directory) @@ -437,6 +499,10 @@ def __init__( self.microclaw_root = microclaw_root.resolve(strict=False) self.manifest = manifest self._held_lock = held_lock + # Optional UI hook: called with a human-readable status string during + # long per-file operations (backup / restore fsync) so the installer + # can show progress instead of appearing frozen. + self.progress_callback: Callable[[str], None] | None = None roots = trusted_openclaw_prefixes() if trusted_prefixes is None else trusted_prefixes self._trusted_prefixes = {_require_absolute(Path(path), "trusted prefix") for path in roots} self._validate_manifest() @@ -666,6 +732,17 @@ def _shim_backup_path(self, shim: Path, backup_dir: Path | None = None) -> Path: ) return (self.backup_dir if backup_dir is None else backup_dir) / "shims" / relative + def _payload_progress(self, action: str) -> Callable[[int, int], None] | None: + callback = self.progress_callback + if callback is None: + return None + + def report(done: int, total: int) -> None: + if total: + callback(f"{action} ({done:,}/{total:,} files)") + + return report + def backup(self) -> None: self.set_phase(UpgradePhase.BACKING_UP) package_dir = Path(self.manifest.package_dir) @@ -708,7 +785,7 @@ def backup(self) -> None: staging / "shims", staging / "state", ): - _fsync_payload_tree(payload_root) + _fsync_payload_tree(payload_root, self._payload_progress("Backing up OpenClaw files")) metadata_files = { staging / "transaction.json", @@ -774,7 +851,7 @@ def _restore_tree(self, backup: Path, live: Path, failed: Path, existed: bool) - _durable_mkdir(live.parent) staging = live.with_name(f".{live.name}.{uuid.uuid4().hex}.restore") shutil.copytree(backup, staging) - _fsync_payload_tree(staging) + _fsync_payload_tree(staging, self._payload_progress("Restoring OpenClaw files")) self._move_to_failed(live, failed) if staging is not None: _durable_rename(staging, live) @@ -859,6 +936,44 @@ def mark_rollback_failed(self) -> None: if held_lock is not None and not held_lock.released: _RETAINED_FAILED_LOCKS[held_lock.owner_token] = held_lock + def discard(self) -> None: + """Abandon a transaction whose rollback could not complete. + + Removes the on-disk manifest, lock file, and backup tree, and releases + the retained lock so future installs are not permanently blocked by a + ``rollback-failed`` transaction (which stays in ``RECOVERABLE_PHASES`` + and re-acquires the lock on every ``load``). Because restores stage a + copy and only swap the live tree with a final atomic rename, a failure + during staging/fsync leaves the live installation intact; callers should + reinstall OpenClaw to repair any partial state. This never raises. + """ + held_lock = self._held_lock + if held_lock is not None: + if _RETAINED_FAILED_LOCKS.get(held_lock.owner_token) is held_lock: + del _RETAINED_FAILED_LOCKS[held_lock.owner_token] + # Release (close) the lock handle before deleting the lock file so + # Windows does not refuse the unlink for an open file. + held_lock.release(held_lock.owner_token) + self._held_lock = None + + backup_root = self.backup_root + for target in (self.manifest_path, self.lock_path, self.backup_dir): + try: + _force_remove_tree(target) + except OSError: + pass + # Sweep any orphaned staging/quarantine dirs left in the backup root. + try: + if backup_root.exists(): + for child in backup_root.iterdir(): + if child.name.startswith("."): + try: + _force_remove_tree(child) + except OSError: + pass + except OSError: + pass + def process_is_alive(pid: int) -> bool: if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0: diff --git a/deployer/webview_bridge.py b/deployer/webview_bridge.py index f036815..2ef521c 100644 --- a/deployer/webview_bridge.py +++ b/deployer/webview_bridge.py @@ -207,6 +207,7 @@ def __init__(self, logger=None): "allow_read": self._default_allow_read, "progress": 0, "progress_text": "", + "progress_detail": "", "status": "idle", "running": False, "error": "", @@ -329,11 +330,23 @@ def _set_progress(self, pct, text): with self._state_lock: self._state["progress"] = pct self._state["progress_text"] = text + # A new step supersedes any sub-status detail from the previous one. + self._state["progress_detail"] = "" self._state["status"] = "running" self._state["running"] = True + def _set_progress_detail(self, detail): + """Sub-status line fed by long-running file operations (backup/restore). + + Keeps the installer from looking frozen during the multi-minute + per-file fsync of a large OpenClaw package. + """ + with self._state_lock: + self._state["progress_detail"] = detail or "" + def _install_thread(self): ws = WindowsSetup(self._config, self._logger) + ws.progress_callback = self._set_progress_detail steps = [ ( diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index beb23fd..5560d95 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -133,6 +133,9 @@ def __init__(self, config, logger: DeployerLogger): self._git_bin: str | None = None # path to git bin directory self._rollback_actions: list[tuple[str, Callable]] = [] self._openclaw_transaction: OpenClawUpgradeTransaction | None = None + # Optional UI hook forwarded to upgrade transactions so long backup / + # restore file operations can report progress instead of looking frozen. + self.progress_callback: Callable[[str], None] | None = None self.appcontainer_enabled = True # AppContainer sandbox (built-in) self.weixin_plugin_enabled = True # Install by default @@ -1133,7 +1136,28 @@ def _gateway_is_stopped_for_upgrade(self) -> bool: return False return True + def _discard_failed_transaction( + self, transaction: OpenClawUpgradeTransaction, reason: str + ) -> None: + """Abandon a transaction whose rollback failed so future installs work. + + Leaving it in ``rollback-failed`` keeps the manifest recoverable and + retains the upgrade lock, which permanently blocks every subsequent + install with ``UpgradeInProgressError``. Discarding clears that state; + the live installation is left as-is and later steps reinstall OpenClaw. + """ + self.log.warn( + f"OpenClaw upgrade rollback could not complete ({reason}); discarding " + "transaction state so future installs are not blocked. The existing " + "OpenClaw installation was left in place." + ) + try: + transaction.discard() + except Exception as discard_error: + self.log.error(f"Failed to discard OpenClaw upgrade transaction: {discard_error}") + def _rollback_openclaw_transaction(self, transaction: OpenClawUpgradeTransaction) -> bool: + transaction.progress_callback = self.progress_callback process: subprocess.Popen | None = None try: original_phase = transaction.manifest.phase @@ -1150,19 +1174,17 @@ def _rollback_openclaw_transaction(self, transaction: OpenClawUpgradeTransaction self.log.error( "Previous OpenClaw Gateway did not become healthy after rollback" ) - transaction.mark_rollback_failed() + self._discard_failed_transaction( + transaction, "restored gateway did not become healthy" + ) return False transaction.complete_rollback() return True except Exception as error: - if transaction.manifest.phase == UpgradePhase.ROLLING_BACK: - try: - transaction.mark_rollback_failed() - except Exception as persist_error: - error.add_note(f"also failed to persist rollback-failed: {persist_error}") self.log.error( f"Failed to restore OpenClaw backup at {transaction.backup_dir}: {error}" ) + self._discard_failed_transaction(transaction, str(error) or error.__class__.__name__) return False finally: self._stop_validation_gateway(process) @@ -1213,6 +1235,7 @@ def prepare_openclaw_upgrade(self) -> bool: installation=source, ) self._openclaw_transaction = transaction + transaction.progress_callback = self.progress_callback if not self._gateway_is_stopped_for_upgrade(): transaction.close() self._openclaw_transaction = None diff --git a/tests/test_openclaw_upgrade.py b/tests/test_openclaw_upgrade.py index 9a69211..aa3f27e 100644 --- a/tests/test_openclaw_upgrade.py +++ b/tests/test_openclaw_upgrade.py @@ -4,6 +4,7 @@ import multiprocessing import os import shutil +import stat import subprocess import tempfile import unittest @@ -174,6 +175,38 @@ def test_fsync_helpers_are_available(self) -> None: self.assertTrue(hasattr(upgrade, "_fsync_file")) self.assertTrue(hasattr(upgrade, "_fsync_directory")) + def test_fsync_file_flushes_read_only_file_and_restores_attribute(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "readonly.bin" + path.write_bytes(b"payload") + os.chmod(path, stat.S_IREAD) + original_mode = path.stat().st_mode + + # Must not raise even though the file carries the read-only bit + # (npm packages ship many such files; the old "rb+" open failed + # with WinError 5 and aborted rollback). + upgrade._fsync_file(path) + + self.assertEqual(path.stat().st_mode, original_mode) + self.assertEqual(path.read_bytes(), b"payload") + + def test_fsync_payload_tree_reports_progress_for_every_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index in range(5): + (root / f"file-{index}.bin").write_bytes(b"x") + (root / "nested").mkdir() + (root / "nested" / "deep.bin").write_bytes(b"y") + + observed: list[tuple[int, int]] = [] + upgrade._fsync_payload_tree( + root, on_progress=lambda done, total: observed.append((done, total)) + ) + + self.assertTrue(observed) + # Final callback reports completion of all six files. + self.assertEqual(observed[-1], (6, 6)) + def test_atomic_json_fsyncs_file_before_durable_replace(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manifest.json" @@ -880,7 +913,7 @@ def test_backup_flushes_staging_and_publishes_before_installing(self) -> None: real_atomic_write = upgrade._atomic_json_write real_rename = upgrade._durable_rename - def flush_tree(path: Path) -> None: + def flush_tree(path: Path, on_progress=None) -> None: events.append(("payload-flush", path, None)) real_flush_tree(path) @@ -1158,7 +1191,7 @@ def test_rollback_flushes_staging_before_durable_restore_and_rolled_back(self) - real_replace = upgrade._durable_replace real_set_phase = tx.set_phase - def flush_tree(path: Path) -> None: + def flush_tree(path: Path, on_progress=None) -> None: events.append(("tree-flush", path, None)) real_flush_tree(path) @@ -1567,6 +1600,39 @@ def test_rollback_failure_is_persisted_and_reraised(self) -> None: self.assertEqual(persisted["phase"], "rollback-failed") self.assertTrue(self.lock_path.exists()) + def test_discard_clears_failed_transaction_and_unblocks_future_installs(self) -> None: + tx = self._create() + tx.backup() + # Add a read-only file to the backup so discard must tolerate the + # read-only attribute when removing the tree (as real npm files do). + readonly_backup_file = tx.backup_dir / "package" / "old.txt" + readonly_backup_file.parent.mkdir(parents=True, exist_ok=True) + readonly_backup_file.write_text("keep", encoding="utf-8") + os.chmod(readonly_backup_file, stat.S_IREAD) + with ( + unittest.mock.patch( + "deployer.openclaw_upgrade.shutil.copytree", + side_effect=OSError("restore failed"), + ), + self.assertRaises(OSError), + ): + tx.rollback() + self.assertEqual(tx.manifest.phase, UpgradePhase.ROLLBACK_FAILED) + + held_lock = tx._held_lock + tx.discard() + + # On-disk transaction state is gone, and the retained lock is released. + self.assertFalse(tx.manifest_path.exists()) + self.assertFalse(self.lock_path.exists()) + self.assertFalse(tx.backup_dir.exists()) + self.assertNotIn(held_lock, upgrade._RETAINED_FAILED_LOCKS.values()) + # A fresh install is no longer blocked: load() finds nothing and a new + # transaction can acquire the lock. + self.assertIsNone(self._load()) + replacement = self._create() + self.assertEqual(replacement.manifest.phase, UpgradePhase.BACKING_UP) + def test_commit_requires_nonempty_all_true_validations(self) -> None: tx = self._create() tx.backup() diff --git a/tests/test_windows_setup_upgrade.py b/tests/test_windows_setup_upgrade.py index b4f95ed..70d82f5 100644 --- a/tests/test_windows_setup_upgrade.py +++ b/tests/test_windows_setup_upgrade.py @@ -66,6 +66,7 @@ def setUp(self): self.ws._git_bin = None self.ws._rollback_actions = [] self.ws._openclaw_transaction = None + self.ws.progress_callback = None self.ws.appcontainer_enabled = True self.ws.weixin_plugin_enabled = True self.ws._is_tcp_port_open = unittest.mock.Mock(return_value=False) @@ -480,7 +481,7 @@ def test_new_install_rollback_does_not_start_a_missing_previous_gateway(self): transaction.complete_rollback.assert_called_once() self.ws._start_validation_gateway.assert_not_called() - def test_failed_rollback_health_check_remains_recoverable(self): + def test_failed_rollback_health_check_discards_transaction(self): transaction = unittest.mock.Mock() transaction.manifest.source_version = "2026.3.12" transaction.manifest.phase = UpgradePhase.ROLLING_BACK @@ -491,9 +492,11 @@ def test_failed_rollback_health_check_remains_recoverable(self): self.assertFalse(self.ws.rollback_openclaw_upgrade()) - transaction.mark_rollback_failed.assert_called_once() + # A failed post-rollback health check must not leave a permanent + # rollback-failed brick; the transaction is discarded so future + # installs are not blocked by the retained lock. + transaction.discard.assert_called_once() transaction.complete_rollback.assert_not_called() - self.assertIs(self.ws._openclaw_transaction, transaction) def test_desktop_update_preserves_upgrade_transaction_directories(self): install_dir = self.root / ".microclaw"