diff --git a/src/transcription/ui/runtime_settings_store.py b/src/transcription/ui/runtime_settings_store.py index b45aa15..2b4a881 100644 --- a/src/transcription/ui/runtime_settings_store.py +++ b/src/transcription/ui/runtime_settings_store.py @@ -6,6 +6,9 @@ import os import re import tempfile from dataclasses import dataclass +from errno import EBUSY +from errno import EPERM +from errno import EXDEV from pathlib import Path from typing import Literal @@ -518,6 +521,7 @@ def _upsert_env_key(lines: list[str], key: str, value: str) -> None: def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None: + temp_path: Path | None = None try: path.parent.mkdir(parents=True, exist_ok=True) content = "\n".join(lines).rstrip("\n") @@ -533,7 +537,15 @@ def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None: ) as handle: temp_path = Path(handle.name) handle.write(content) - temp_path.replace(path) + try: + temp_path.replace(path) + except OSError as exc: + # Single-file bind mounts can reject replace() (cross-device or busy mountpoint). + # Fallback to direct write so Runtime Settings can persist to mounted env files. + if exc.errno not in {EXDEV, EBUSY, EPERM}: + raise + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(content) except OSError as exc: raise AppError( "Runtime settings file is not writable.", @@ -541,6 +553,9 @@ def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None: suggestion="Verify file path and write permissions, then retry.", detail=f"Failed writing runtime env file {path}: {type(exc).__name__}: {exc}", ) from exc + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) def _escape_json_string(value: str) -> str: diff --git a/tests/ui/test_runtime_settings_store.py b/tests/ui/test_runtime_settings_store.py index e669434..77281ab 100644 --- a/tests/ui/test_runtime_settings_store.py +++ b/tests/ui/test_runtime_settings_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import errno from pathlib import Path import pytest @@ -132,3 +133,18 @@ def test_save_runtime_settings_surfaces_unwritable_target(tmp_path: Path, monkey with pytest.raises(AppError) as exc: save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path) assert exc.value.message == "Runtime settings file is not writable." + + +def test_save_runtime_settings_falls_back_when_atomic_replace_is_unavailable(tmp_path: Path, monkeypatch): + settings = _settings_for_runtime_editing(tmp_path) + env_path = tmp_path / ".env.production" + env_path.write_text("OPENROUTER_API_KEY=test-key\nPORT=8000\n", encoding="utf-8") + + def _replace_cross_device(_self: Path, _target: Path) -> Path: + raise OSError(errno.EXDEV, "Invalid cross-device link") + + monkeypatch.setattr(Path, "replace", _replace_cross_device) + snapshot = save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path) + + assert snapshot.env_file_path == env_path + assert "PORT=9001" in env_path.read_text(encoding="utf-8")