Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion pandrator_installer/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/test_web_supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down