|
8 | 8 |
|
9 | 9 | import json |
10 | 10 | import os |
| 11 | +import threading |
11 | 12 | from dataclasses import dataclass |
12 | 13 | from pathlib import Path |
13 | 14 |
|
@@ -51,18 +52,33 @@ def _ensure_session_dir() -> None: |
51 | 52 |
|
52 | 53 |
|
53 | 54 | def _write_file_secure(path: Path, content: str) -> None: |
54 | | - """Write content to a file with 0600 permissions (owner-only read/write).""" |
55 | | - fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
| 55 | + """Write content atomically with 0600 permissions. |
| 56 | +
|
| 57 | + Writes to a temp file then replaces the target, preventing partial |
| 58 | + reads and the truncation race where two concurrent O_TRUNC opens |
| 59 | + leave trailing bytes from the longer write. |
| 60 | + """ |
| 61 | + tmp = str(path) + ".tmp" |
| 62 | + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
56 | 63 | with os.fdopen(fd, "w") as f: |
57 | 64 | f.write(content) |
| 65 | + os.replace(tmp, str(path)) |
| 66 | + |
| 67 | + |
| 68 | +_state_lock = threading.Lock() |
58 | 69 |
|
59 | 70 |
|
60 | 71 | def write_session_state(updates: dict) -> None: |
61 | | - """Atomic read-merge-write of session state. Creates file if missing.""" |
| 72 | + """Atomic read-merge-write of session state. Creates file if missing. |
| 73 | +
|
| 74 | + Thread-safe: a lock serialises concurrent updates from the poll |
| 75 | + thread and action handlers to prevent read-merge-write clobbering. |
| 76 | + """ |
62 | 77 | _ensure_session_dir() |
63 | | - current = read_session_state() |
64 | | - current.update(updates) |
65 | | - _write_file_secure(STATE_FILE, json.dumps(current, indent=2, default=str)) |
| 78 | + with _state_lock: |
| 79 | + current = read_session_state() |
| 80 | + current.update(updates) |
| 81 | + _write_file_secure(STATE_FILE, json.dumps(current, indent=2, default=str)) |
66 | 82 |
|
67 | 83 |
|
68 | 84 | def parse_topics_file() -> list[TopicEntry]: |
|
0 commit comments