fix(host-workspace): make config writes atomic and lock-protected
This commit is contained in:
parent
6aba89ae9b
commit
f198c0c63e
@ -3,7 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
@ -11,6 +14,7 @@ from config import DEFAULT_PROJECT_PATH, HOST_WORKSPACES_FILE
|
|||||||
|
|
||||||
_WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
|
_WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_HOST_WORKSPACE_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path:
|
def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path:
|
||||||
@ -43,17 +47,39 @@ def _default_payload() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _ensure_config_file(path: Path) -> Dict[str, Any]:
|
def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fp:
|
||||||
|
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||||
|
fp.flush()
|
||||||
|
os.fsync(fp.fileno())
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_config_file(path: Path, *, strict: bool = False) -> Dict[str, Any]:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
except Exception:
|
if strict:
|
||||||
pass
|
raise RuntimeError("host_workspaces 配置格式错误(非 JSON 对象),已停止写入以避免覆盖原文件")
|
||||||
|
except Exception as exc:
|
||||||
|
if strict:
|
||||||
|
raise RuntimeError(f"host_workspaces 配置解析失败,已停止写入以避免覆盖原文件: {exc}") from exc
|
||||||
|
# 只回退到内存默认值,不覆盖磁盘文件
|
||||||
|
return _default_payload()
|
||||||
data = _default_payload()
|
data = _default_payload()
|
||||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
_atomic_write_json(path, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@ -107,7 +133,8 @@ def load_host_workspace_catalog(
|
|||||||
config_path: Optional[Union[str, Path]] = None,
|
config_path: Optional[Union[str, Path]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
cfg_path = _resolve_config_path(config_path)
|
cfg_path = _resolve_config_path(config_path)
|
||||||
payload = _ensure_config_file(cfg_path)
|
with _HOST_WORKSPACE_LOCK:
|
||||||
|
payload = _ensure_config_file(cfg_path, strict=False)
|
||||||
raw_workspaces = payload.get("workspaces")
|
raw_workspaces = payload.get("workspaces")
|
||||||
if not isinstance(raw_workspaces, list):
|
if not isinstance(raw_workspaces, list):
|
||||||
raw_workspaces = []
|
raw_workspaces = []
|
||||||
@ -174,7 +201,8 @@ def create_host_workspace(
|
|||||||
config_path: Optional[Union[str, Path]] = None,
|
config_path: Optional[Union[str, Path]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
cfg_path = _resolve_config_path(config_path)
|
cfg_path = _resolve_config_path(config_path)
|
||||||
payload = _ensure_config_file(cfg_path)
|
with _HOST_WORKSPACE_LOCK:
|
||||||
|
payload = _ensure_config_file(cfg_path, strict=True)
|
||||||
|
|
||||||
raw_workspaces = payload.get("workspaces")
|
raw_workspaces = payload.get("workspaces")
|
||||||
if not isinstance(raw_workspaces, list):
|
if not isinstance(raw_workspaces, list):
|
||||||
@ -222,8 +250,7 @@ def create_host_workspace(
|
|||||||
if set_default or not default_id:
|
if set_default or not default_id:
|
||||||
payload["default_workspace_id"] = workspace_id
|
payload["default_workspace_id"] = workspace_id
|
||||||
|
|
||||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
_atomic_write_json(cfg_path, payload)
|
||||||
cfg_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"created": True,
|
"created": True,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user