diff --git a/pandrator_installer/supervisor.py b/pandrator_installer/supervisor.py index 53f41d7e..8449022a 100644 --- a/pandrator_installer/supervisor.py +++ b/pandrator_installer/supervisor.py @@ -211,7 +211,30 @@ def _write_state(self) -> None: } temporary = self.runtime_state.with_suffix(".tmp") temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8") - os.replace(temporary, self.runtime_state) + attempts = 10 + for attempt in range(1, attempts + 1): + try: + os.replace(temporary, self.runtime_state) + return + except OSError: + if attempt == attempts: + # The state file is informational (GUI status display and + # discovery of already-running services) and is rewritten on + # the next monitor tick. On Windows, a concurrent reader + # holds the destination open without FILE_SHARE_DELETE, so + # os.replace() fails with WinError 5; that must not take + # down the whole supervised process tree over a skipped + # status update. + logging.exception( + "Could not update %s; keeping the previous state file.", + self.runtime_state, + ) + try: + temporary.unlink() + except OSError: + pass + return + time.sleep(0.1) def _managed_process_environment(self, spec: ManagedProcessSpec) -> dict[str, str]: # The frozen installer needs its private libraries, but the installed diff --git a/tests/test_web_supervisor.py b/tests/test_web_supervisor.py index 3824930c..d17db309 100644 --- a/tests/test_web_supervisor.py +++ b/tests/test_web_supervisor.py @@ -139,6 +139,41 @@ def observe_callback_state(): finally: supervisor.stop_all() + def test_write_state_retries_when_replace_is_briefly_blocked(self): + # On Windows a concurrent reader of runtime-processes.json (for example + # the installer GUI's status poll) holds the file open without + # FILE_SHARE_DELETE, so os.replace() fails with PermissionError. + with tempfile.TemporaryDirectory() as directory: + supervisor = ProcessSupervisor(data_root=directory, specs=[]) + real_replace = os.replace + failures = iter([PermissionError(13, "Access is denied"), PermissionError(13, "Access is denied")]) + + def flaky_replace(source, destination): + try: + raise next(failures) + except StopIteration: + real_replace(source, destination) + + with mock.patch("pandrator_installer.supervisor.os.replace", side_effect=flaky_replace), mock.patch( + "pandrator_installer.supervisor.time.sleep" + ): + supervisor._write_state() + + state = json.loads((Path(directory) / "runtime-processes.json").read_text(encoding="utf-8")) + self.assertEqual(state["supervisor_pid"], os.getpid()) + + def test_write_state_survives_a_persistently_blocked_replace(self): + with tempfile.TemporaryDirectory() as directory: + supervisor = ProcessSupervisor(data_root=directory, specs=[]) + with mock.patch( + "pandrator_installer.supervisor.os.replace", + side_effect=PermissionError(13, "Access is denied"), + ), mock.patch("pandrator_installer.supervisor.time.sleep"): + supervisor._write_state() # must not raise + + self.assertFalse((Path(directory) / "runtime-processes.json").exists()) + self.assertFalse((Path(directory) / "runtime-processes.tmp").exists()) + def test_control_request_stops_only_the_requested_service_without_restart(self): with tempfile.TemporaryDirectory() as directory: specs = [