"""Per-op timeout guard for SFTP operations (SP27 Task 8). paramiko is synchronous; without a timeout, a hung ``listdir_attr`` freezes the worker thread indefinitely. The 06/25 silent hang was exactly this — the MFT server TCP-acked but stopped responding, the scheduler's ``asyncio.to_thread`` waited forever, and the operator had no signal that polling had stalled. This test pins the fix: the SFTP client's only async-wrapped call site (``async_list_inbound``) applies an ``asyncio.wait_for(... timeout=N)`` so the event loop can give up after the configured bound. The bound is read from ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` at call time (default 30s). The other SFTP methods (``list_inbound_names``, ``download_inbound``, ``read_file``, ``write_file``) intentionally remain sync — they're called from operator-triggered paths (admin endpoints, CLI) where the operator can Ctrl-C the request. Wrapping them would require making the FastAPI handlers themselves async, which is out of scope for Task 8. Tracked as a follow-up. """ from __future__ import annotations import asyncio import time import pytest from cyclone.clearhouse import SftpClient from cyclone.providers import SftpBlock def _block() -> SftpBlock: return SftpBlock( host="mft.example.com", port=22, username="user", auth={"password_keychain_account": "x"}, paths={"inbound": "/inbound", "outbound": "/outbound"}, stub=True, ) class _HangingClient(SftpClient): """SftpClient that hangs on list_inbound — exercises the asyncio.wait_for timeout. The hang runs in a thread, so ``asyncio.to_thread`` alone would never give up; only the wait_for wrapper can break it. The hang is short (3s) so the per-test thread pool doesn't accumulate stragglers across the timeout tests. Each test sets ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS=1`` so wait_for fires well before the sleep completes. """ def list_inbound(self): time.sleep(3) return [] # ---- env-var parsing -------------------------------------------------------- def test_op_timeout_default_is_30_seconds(monkeypatch): """Default is 30s when CYCLONE_SFTP_OP_TIMEOUT_SECONDS is unset. Pins the documented default so a refactor that changes it has to update both the constant and the spec. """ monkeypatch.delenv("CYCLONE_SFTP_OP_TIMEOUT_SECONDS", raising=False) from cyclone.clearhouse import _op_timeout_seconds assert _op_timeout_seconds() == 30.0 def test_op_timeout_reads_env_var(monkeypatch): """Operator can override via env var without a restart-rebuild.""" monkeypatch.setenv("CYCLONE_SFTP_OP_TIMEOUT_SECONDS", "7.5") from cyclone.clearhouse import _op_timeout_seconds assert _op_timeout_seconds() == 7.5 def test_op_timeout_rejects_unparseable_env_var(monkeypatch): """A non-float value (e.g. "30s") falls back to the default. Pins the ValueError branch — a refactor that drops the try/except would crash the SFTP wrapper on every call when the env var is set to anything non-numeric. """ monkeypatch.setenv("CYCLONE_SFTP_OP_TIMEOUT_SECONDS", "30s") from cyclone.clearhouse import _op_timeout_seconds assert _op_timeout_seconds() == 30.0 def test_op_timeout_rejects_zero_and_negative(monkeypatch): """Zero / negative timeouts would silently turn every SFTP call into an instant failure (asyncio.wait_for(timeout=0) raises immediately; timeout<0 is undefined). They fall back to default. """ from cyclone.clearhouse import _op_timeout_seconds for bad in ("0", "0.0", "-1", "-0.5"): monkeypatch.setenv("CYCLONE_SFTP_OP_TIMEOUT_SECONDS", bad) assert _op_timeout_seconds() == 30.0, f"value {bad!r} should fall back to default" # ---- async wrapper behavior ------------------------------------------------- @pytest.mark.asyncio async def test_async_list_inbound_times_out_when_paramiko_hangs(monkeypatch): """A hanging list_inbound must be cancelled by wait_for, not the thread. The 06/25 silent hang was exactly this scenario — paramiko's listdir_attr TCP-acked then went silent, freezing the worker thread. The wait_for wrapper is the only thing that can break out of that. """ monkeypatch.setenv("CYCLONE_SFTP_OP_TIMEOUT_SECONDS", "1") client = _HangingClient(_block()) start = time.monotonic() with pytest.raises(asyncio.TimeoutError): await client.async_list_inbound() elapsed = time.monotonic() - start # Loose upper bound: timeout fires at ~1s, allow 3s for asyncio # scheduling jitter on slow CI runners. If this ever fires at the # hang duration (3s) the wait_for wrapper is missing. assert elapsed < 3, f"timeout fired too late: {elapsed:.2f}s" @pytest.mark.asyncio async def test_async_list_inbound_returns_normally_when_fast(monkeypatch, tmp_path): """Sanity check: a non-hanging call returns the stub's empty list without raising — pins that the wrapper doesn't break the happy path.""" monkeypatch.setenv("CYCLONE_SFTP_OP_TIMEOUT_SECONDS", "5") block = _block() block.staging_dir = str(tmp_path) client = SftpClient(block) # stub mode + no files staged = empty list, no hang, no timeout result = await client.async_list_inbound() assert result == []