diff --git a/tests/test_sense.py b/tests/test_sense.py index 9a7bd373a..fa4f0e167 100644 --- a/tests/test_sense.py +++ b/tests/test_sense.py @@ -210,6 +210,33 @@ def test_process_log_writer_thread_safe(tmp_path, monkeypatch): assert len([line for line in lines if line]) == 50 +def test_process_log_writer_pins_journal_root_at_init(tmp_path, monkeypatch): + """Env-var drift between construction and flush must not redirect writes.""" + from think import runner + + journal_a = tmp_path / "a" + journal_b = tmp_path / "b" + journal_a.mkdir() + journal_b.mkdir() + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_a)) + monkeypatch.setattr(runner, "_current_day", lambda: "20241101") + + ref = "test_ref" + writer = ProcessLogWriter(ref, "echo") + + # Drift: env var changes and day changes before the next flush. + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_b)) + monkeypatch.setattr(runner, "_current_day", lambda: "20241102") + + writer.write("hello\n") + writer.close() + + leaked_paths = list(journal_b.rglob("*")) + assert not leaked_paths, f"writes leaked into drifted journal: {leaked_paths}" + assert list(journal_a.rglob("*.log")) or list(journal_a.rglob("*echo*")) + + def test_handler_process_cleanup(): """Test HandlerProcess cleanup joins threads and closes logger.""" mock_managed = MagicMock() diff --git a/think/runner.py b/think/runner.py index 9b531feb8..29075fc30 100644 --- a/think/runner.py +++ b/think/runner.py @@ -43,12 +43,12 @@ def _current_day() -> str: return datetime.now().strftime("%Y%m%d") -def _day_health_log_path(day: str, ref: str, name: str) -> Path: +def _day_health_log_path(journal_root: Path, day: str, ref: str, name: str) -> Path: """Build path to day health log. Returns: journal/chronicle/{day}/health/{ref}_{name}.log """ - return _get_journal_path() / CHRONICLE_DIR / day / "health" / f"{ref}_{name}.log" + return journal_root / CHRONICLE_DIR / day / "health" / f"{ref}_{name}.log" def _atomic_symlink(link_path: Path, target: str) -> None: @@ -98,11 +98,14 @@ class DailyLogWriter: - journal/health/{name}.log -> chronicle/{YYYYMMDD}/health/{ref}_{name}.log (journal-level) When the day changes, automatically closes old file, opens new file, and updates symlinks. + The journal root is resolved once at construction time and pinned for the + lifetime of the writer. """ def __init__(self, ref: str, name: str, day: str | None = None): self._ref = ref self._name = name + self._journal_root: Path = _get_journal_path() self._pinned = day is not None self._lock = threading.Lock() self._current_day = day or _current_day() @@ -111,13 +114,15 @@ class DailyLogWriter: def _open_log(self): """Open log file for current day.""" - log_path = _day_health_log_path(self._current_day, self._ref, self._name) + log_path = _day_health_log_path( + self._journal_root, self._current_day, self._ref, self._name + ) log_path.parent.mkdir(parents=True, exist_ok=True) return log_path.open("a", encoding="utf-8") def _update_symlinks(self) -> None: """Update day-level and journal-level symlinks to point to current log.""" - journal = _get_journal_path() + journal = self._journal_root day_health = journal / CHRONICLE_DIR / self._current_day / "health" log_filename = f"{self._ref}_{self._name}.log" @@ -167,7 +172,9 @@ class DailyLogWriter: @property def path(self) -> Path: """Get current log file path.""" - return _day_health_log_path(self._current_day, self._ref, self._name) + return _day_health_log_path( + self._journal_root, self._current_day, self._ref, self._name + ) @dataclass