| """Tests for FileSyncManager.sync_back() — pull remote changes to host.""" |
|
|
| import fcntl |
| import io |
| import logging |
| import os |
| import signal |
| import tarfile |
| import time |
| from pathlib import Path |
| from unittest.mock import MagicMock, call, patch |
|
|
| import pytest |
|
|
| from tools.environments.file_sync import ( |
| FileSyncManager, |
| _sha256_file, |
| _SYNC_BACK_BACKOFF, |
| _SYNC_BACK_MAX_RETRIES, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _make_tar(files: dict[str, bytes], dest: Path): |
| """Write a tar archive containing the given arcname->content pairs.""" |
| with tarfile.open(dest, "w") as tar: |
| for arcname, content in files.items(): |
| info = tarfile.TarInfo(name=arcname) |
| info.size = len(content) |
| tar.addfile(info, io.BytesIO(content)) |
|
|
|
|
| def _make_download_fn(files: dict[str, bytes]): |
| """Return a bulk_download_fn that writes a tar of the given files.""" |
| def download(dest: Path): |
| _make_tar(files, dest) |
| return download |
|
|
|
|
| def _sha256_bytes(data: bytes) -> str: |
| """Compute SHA-256 hex digest of raw bytes (for test convenience).""" |
| import hashlib |
| return hashlib.sha256(data).hexdigest() |
|
|
|
|
| def _write_file(path: Path, content: bytes) -> str: |
| """Write bytes to *path*, creating parents, and return the string path.""" |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_bytes(content) |
| return str(path) |
|
|
|
|
| def _make_manager( |
| tmp_path: Path, |
| file_mapping: list[tuple[str, str]] | None = None, |
| bulk_download_fn=None, |
| seed_pushed_state: bool = True, |
| ) -> FileSyncManager: |
| """Create a FileSyncManager wired for testing. |
| |
| *file_mapping* is a list of (host_path, remote_path) tuples that |
| ``get_files_fn`` returns. If *None* an empty list is used. |
| |
| When *seed_pushed_state* is True (default), populate ``_pushed_hashes`` |
| from the mapping so sync_back doesn't early-return on the "nothing |
| previously pushed" guard. Set False to test the noop path. |
| """ |
| mapping = file_mapping or [] |
| mgr = FileSyncManager( |
| get_files_fn=lambda: mapping, |
| upload_fn=MagicMock(), |
| delete_fn=MagicMock(), |
| bulk_download_fn=bulk_download_fn, |
| ) |
| if seed_pushed_state: |
| |
| |
| |
| for host_path, remote_path in mapping: |
| if os.path.exists(host_path): |
| mgr._pushed_hashes[remote_path] = _sha256_file(host_path) |
| else: |
| mgr._pushed_hashes[remote_path] = "0" * 64 |
| if not mgr._pushed_hashes: |
| mgr._pushed_hashes["/_sentinel"] = "0" * 64 |
| return mgr |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestSyncBackNoop: |
| """sync_back() is a no-op when there is no download function.""" |
|
|
| def test_sync_back_noop_without_download_fn(self, tmp_path): |
| mgr = _make_manager(tmp_path, bulk_download_fn=None) |
| |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
| |
|
|
|
|
| class TestSyncBackNoChanges: |
| """When all remote files match pushed hashes, nothing is applied.""" |
|
|
| def test_sync_back_no_changes(self, tmp_path): |
| host_file = tmp_path / "host" / "cred.json" |
| host_content = b'{"key": "val"}' |
| _write_file(host_file, host_content) |
|
|
| remote_path = "/root/.hermes/cred.json" |
| mapping = [(str(host_file), remote_path)] |
|
|
| |
| download_fn = _make_download_fn({ |
| "root/.hermes/cred.json": host_content, |
| }) |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn) |
| |
| mgr._pushed_hashes[remote_path] = _sha256_bytes(host_content) |
|
|
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| assert host_file.read_bytes() == host_content |
|
|
|
|
| class TestSyncBackAppliesChanged: |
| """Remote file differs from pushed version -- gets copied to host.""" |
|
|
| def test_sync_back_applies_changed_file(self, tmp_path): |
| host_file = tmp_path / "host" / "skill.py" |
| original_content = b"print('v1')" |
| _write_file(host_file, original_content) |
|
|
| remote_path = "/root/.hermes/skill.py" |
| mapping = [(str(host_file), remote_path)] |
|
|
| remote_content = b"print('v2 - edited on remote')" |
| download_fn = _make_download_fn({ |
| "root/.hermes/skill.py": remote_content, |
| }) |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn) |
| mgr._pushed_hashes[remote_path] = _sha256_bytes(original_content) |
|
|
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| assert host_file.read_bytes() == remote_content |
|
|
|
|
| class TestSyncBackNewRemoteFile: |
| """File created on remote (not in _pushed_hashes) is applied via _infer_host_path.""" |
|
|
| def test_sync_back_detects_new_remote_file(self, tmp_path): |
| |
| existing_host = tmp_path / "host" / "skills" / "existing.py" |
| _write_file(existing_host, b"existing") |
| mapping = [(str(existing_host), "/root/.hermes/skills/existing.py")] |
|
|
| |
| new_remote_content = b"# brand new skill created on remote" |
| download_fn = _make_download_fn({ |
| "root/.hermes/skills/new_skill.py": new_remote_content, |
| }) |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn) |
| |
|
|
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| expected_host_path = tmp_path / "host" / "skills" / "new_skill.py" |
| assert expected_host_path.exists() |
| assert expected_host_path.read_bytes() == new_remote_content |
|
|
|
|
| class TestSyncBackConflict: |
| """Host AND remote both changed since push -- warning logged, remote wins.""" |
|
|
| def test_sync_back_conflict_warns(self, tmp_path, caplog): |
| host_file = tmp_path / "host" / "config.json" |
| original_content = b'{"v": 1}' |
| _write_file(host_file, original_content) |
|
|
| remote_path = "/root/.hermes/config.json" |
| mapping = [(str(host_file), remote_path)] |
|
|
| |
| host_file.write_bytes(b'{"v": 2, "host-edit": true}') |
|
|
| |
| remote_content = b'{"v": 3, "remote-edit": true}' |
| download_fn = _make_download_fn({ |
| "root/.hermes/config.json": remote_content, |
| }) |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn) |
| mgr._pushed_hashes[remote_path] = _sha256_bytes(original_content) |
|
|
| with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"): |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| assert any("conflict" in r.message.lower() for r in caplog.records) |
|
|
| |
| assert host_file.read_bytes() == remote_content |
|
|
|
|
| class TestSyncBackRetries: |
| """Retry behaviour with exponential backoff.""" |
|
|
| @patch("tools.environments.file_sync.time.sleep") |
| def test_sync_back_retries_on_failure(self, mock_sleep, tmp_path): |
| call_count = 0 |
|
|
| def flaky_download(dest: Path): |
| nonlocal call_count |
| call_count += 1 |
| if call_count < 3: |
| raise RuntimeError(f"network error #{call_count}") |
| |
| _make_tar({}, dest) |
|
|
| mgr = _make_manager(tmp_path, bulk_download_fn=flaky_download) |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| assert call_count == 3 |
| |
| assert mock_sleep.call_count == 2 |
| mock_sleep.assert_any_call(_SYNC_BACK_BACKOFF[0]) |
| mock_sleep.assert_any_call(_SYNC_BACK_BACKOFF[1]) |
|
|
| @patch("tools.environments.file_sync.time.sleep") |
| def test_sync_back_all_retries_exhausted(self, mock_sleep, tmp_path, caplog): |
| def always_fail(dest: Path): |
| raise RuntimeError("persistent failure") |
|
|
| mgr = _make_manager(tmp_path, bulk_download_fn=always_fail) |
|
|
| with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"): |
| |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| assert mock_sleep.call_count == _SYNC_BACK_MAX_RETRIES - 1 |
|
|
| |
| assert any("all" in r.message.lower() and "failed" in r.message.lower() for r in caplog.records) |
|
|
|
|
| class TestPushedHashesPopulated: |
| """_pushed_hashes is populated during sync() and cleared on delete.""" |
|
|
| def test_pushed_hashes_populated_on_sync(self, tmp_path): |
| host_file = tmp_path / "data.txt" |
| host_file.write_bytes(b"hello world") |
|
|
| remote_path = "/root/.hermes/data.txt" |
| mapping = [(str(host_file), remote_path)] |
|
|
| mgr = FileSyncManager( |
| get_files_fn=lambda: mapping, |
| upload_fn=MagicMock(), |
| delete_fn=MagicMock(), |
| ) |
|
|
| mgr.sync(force=True) |
|
|
| assert remote_path in mgr._pushed_hashes |
| assert mgr._pushed_hashes[remote_path] == _sha256_file(str(host_file)) |
|
|
| def test_pushed_hashes_cleared_on_delete(self, tmp_path): |
| host_file = tmp_path / "deleteme.txt" |
| host_file.write_bytes(b"to be deleted") |
|
|
| remote_path = "/root/.hermes/deleteme.txt" |
| mapping = [(str(host_file), remote_path)] |
| current_mapping = list(mapping) |
|
|
| mgr = FileSyncManager( |
| get_files_fn=lambda: current_mapping, |
| upload_fn=MagicMock(), |
| delete_fn=MagicMock(), |
| ) |
|
|
| |
| mgr.sync(force=True) |
| assert remote_path in mgr._pushed_hashes |
|
|
| |
| os.unlink(str(host_file)) |
| current_mapping.clear() |
|
|
| mgr.sync(force=True) |
|
|
| |
| assert remote_path not in mgr._pushed_hashes |
|
|
|
|
| class TestSyncBackFileLock: |
| """Verify that fcntl.flock is used during sync-back.""" |
|
|
| @patch("tools.environments.file_sync.fcntl.flock") |
| def test_sync_back_file_lock(self, mock_flock, tmp_path): |
| download_fn = _make_download_fn({}) |
| mgr = _make_manager(tmp_path, bulk_download_fn=download_fn) |
|
|
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| assert mock_flock.call_count >= 2 |
|
|
| lock_calls = mock_flock.call_args_list |
| lock_ops = [c[0][1] for c in lock_calls] |
| assert fcntl.LOCK_EX in lock_ops |
| assert fcntl.LOCK_UN in lock_ops |
|
|
| def test_sync_back_skips_flock_when_fcntl_none(self, tmp_path): |
| """On Windows (fcntl=None), sync_back should skip file locking.""" |
| download_fn = _make_download_fn({}) |
| mgr = _make_manager(tmp_path, bulk_download_fn=download_fn) |
|
|
| with patch("tools.environments.file_sync.fcntl", None): |
| |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
|
|
| class TestInferHostPath: |
| """Edge cases for _infer_host_path prefix matching.""" |
|
|
| def test_infer_no_matching_prefix(self, tmp_path): |
| """Remote path in unmapped directory should return None.""" |
| host_file = tmp_path / "host" / "skills" / "a.py" |
| _write_file(host_file, b"content") |
| mapping = [(str(host_file), "/root/.hermes/skills/a.py")] |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping) |
| result = mgr._infer_host_path( |
| "/root/.hermes/cache/new.json", |
| file_mapping=mapping, |
| ) |
| assert result is None |
|
|
| def test_infer_partial_prefix_no_false_match(self, tmp_path): |
| """A partial prefix like /root/.hermes/sk should NOT match /root/.hermes/skills/.""" |
| host_file = tmp_path / "host" / "skills" / "a.py" |
| _write_file(host_file, b"content") |
| mapping = [(str(host_file), "/root/.hermes/skills/a.py")] |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping) |
| |
| |
| result = mgr._infer_host_path( |
| "/root/.hermes/skillsXtra/b.py", |
| file_mapping=mapping, |
| ) |
| assert result is None |
|
|
| def test_infer_matching_prefix(self, tmp_path): |
| """A file in a mapped directory should be correctly inferred.""" |
| host_file = tmp_path / "host" / "skills" / "a.py" |
| _write_file(host_file, b"content") |
| mapping = [(str(host_file), "/root/.hermes/skills/a.py")] |
|
|
| mgr = _make_manager(tmp_path, file_mapping=mapping) |
| result = mgr._infer_host_path( |
| "/root/.hermes/skills/b.py", |
| file_mapping=mapping, |
| ) |
| expected = str(tmp_path / "host" / "skills" / "b.py") |
| assert result == expected |
|
|
|
|
| class TestSyncBackSIGINT: |
| """SIGINT deferral during sync-back.""" |
|
|
| def test_sync_back_defers_sigint_on_main_thread(self, tmp_path): |
| """On the main thread, SIGINT handler should be swapped during sync.""" |
| download_fn = _make_download_fn({}) |
| mgr = _make_manager(tmp_path, bulk_download_fn=download_fn) |
|
|
| handlers_seen = [] |
| original_getsignal = signal.getsignal |
|
|
| with patch("tools.environments.file_sync.signal.getsignal", |
| side_effect=original_getsignal) as mock_get, \ |
| patch("tools.environments.file_sync.signal.signal") as mock_set: |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| assert mock_get.called |
| |
| assert mock_set.call_count >= 2 |
|
|
| def test_sync_back_skips_signal_on_worker_thread(self, tmp_path): |
| """From a non-main thread, signal.signal should NOT be called.""" |
| import threading |
|
|
| download_fn = _make_download_fn({}) |
| mgr = _make_manager(tmp_path, bulk_download_fn=download_fn) |
|
|
| signal_called = [] |
|
|
| def tracking_signal(*args): |
| signal_called.append(args) |
|
|
| with patch("tools.environments.file_sync.signal.signal", side_effect=tracking_signal): |
| |
| exc = [] |
| def run(): |
| try: |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
| except Exception as e: |
| exc.append(e) |
|
|
| t = threading.Thread(target=run) |
| t.start() |
| t.join(timeout=10) |
|
|
| assert not exc, f"sync_back raised: {exc}" |
| |
| assert len(signal_called) == 0 |
|
|
|
|
| class TestSyncBackSizeCap: |
| """The size cap refuses to extract tars above the configured limit.""" |
|
|
| def test_sync_back_refuses_oversized_tar(self, tmp_path, caplog): |
| """A tar larger than _SYNC_BACK_MAX_BYTES should be skipped with a warning.""" |
| |
| |
| skill_host = _write_file(tmp_path / "host_skill.md", b"original") |
| files = {"root/.hermes/skill.md": b"remote_version"} |
| download_fn = _make_download_fn(files) |
|
|
| mgr = _make_manager( |
| tmp_path, |
| file_mapping=[(skill_host, "/root/.hermes/skill.md")], |
| bulk_download_fn=download_fn, |
| ) |
|
|
| |
| with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"): |
| with patch("tools.environments.file_sync._SYNC_BACK_MAX_BYTES", 1): |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
|
|
| |
| assert Path(skill_host).read_bytes() == b"original" |
| |
| assert any("cap" in r.message for r in caplog.records) |
|
|
| def test_sync_back_applies_when_under_cap(self, tmp_path): |
| """A tar under the cap should extract normally (sanity check).""" |
| host_file = _write_file(tmp_path / "host_skill.md", b"original") |
| files = {"root/.hermes/skill.md": b"remote_version"} |
| download_fn = _make_download_fn(files) |
|
|
| mgr = _make_manager( |
| tmp_path, |
| file_mapping=[(host_file, "/root/.hermes/skill.md")], |
| bulk_download_fn=download_fn, |
| ) |
|
|
| |
| mgr.sync_back(hermes_home=tmp_path / ".hermes") |
| assert Path(host_file).read_bytes() == b"remote_version" |
|
|