From 76488139c8b224107047d8e06141e8f5c595f9f2 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 9 Feb 2026 16:45:12 -0500 Subject: [PATCH] Add error handling for process communication failures in IF save/restore --- src/mudlib/if_session.py | 63 +++++++++++++++------------ tests/test_if_session.py | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/src/mudlib/if_session.py b/src/mudlib/if_session.py index 628f70c..6804875 100644 --- a/src/mudlib/if_session.py +++ b/src/mudlib/if_session.py @@ -111,27 +111,30 @@ class IFSession: if not self.process or not self.process.stdin: return "error: game not running" - # Ensure save directory exists - self._ensure_save_dir() + try: + # Ensure save directory exists + self._ensure_save_dir() - # Send "save" command to dfrotz - self.process.stdin.write(b"save\n") - await self.process.stdin.drain() + # Send "save" command to dfrotz + self.process.stdin.write(b"save\n") + await self.process.stdin.drain() - # Read filename prompt - await self._read_response() + # Read filename prompt + await self._read_response() - # Send save file path - save_path_str = str(self.save_path) - self.process.stdin.write(f"{save_path_str}\n".encode()) - await self.process.stdin.drain() + # Send save file path + save_path_str = str(self.save_path) + self.process.stdin.write(f"{save_path_str}\n".encode()) + await self.process.stdin.drain() - # Read confirmation - confirmation = await self._read_response() + # Read confirmation + confirmation = await self._read_response() - # Mark as saved - self._saved = True - return confirmation + # Mark as saved + self._saved = True + return confirmation + except Exception as e: + return f"error: save failed ({e})" async def _do_restore(self) -> str: """Restore game state from disk. Returns game text or empty string.""" @@ -142,21 +145,25 @@ class IFSession: if not self.process or not self.process.stdin: return "" - # Send "restore" command to dfrotz - self.process.stdin.write(b"restore\n") - await self.process.stdin.drain() + try: + # Send "restore" command to dfrotz + self.process.stdin.write(b"restore\n") + await self.process.stdin.drain() - # Read filename prompt - await self._read_response() + # Read filename prompt + await self._read_response() - # Send save file path - save_path_str = str(self.save_path) - self.process.stdin.write(f"{save_path_str}\n".encode()) - await self.process.stdin.drain() + # Send save file path + save_path_str = str(self.save_path) + self.process.stdin.write(f"{save_path_str}\n".encode()) + await self.process.stdin.drain() - # Read game text response - game_text = await self._read_response() - return game_text + # Read game text response + game_text = await self._read_response() + return game_text + except Exception: + # If restore fails, return empty string (player starts fresh) + return "" async def _read_response(self) -> str: """Read dfrotz output until the '>' prompt appears.""" diff --git a/tests/test_if_session.py b/tests/test_if_session.py index 2d89106..994ffc7 100644 --- a/tests/test_if_session.py +++ b/tests/test_if_session.py @@ -683,3 +683,96 @@ async def test_regular_input_resets_save_flag(tmp_path): call for call in mock_process.stdin.write.call_args_list if call[0][0] == b"save\n" ] assert len(save_calls) == 2, "should be able to save again after regular input" + + +@pytest.mark.asyncio +async def test_do_save_handles_process_communication_failure(tmp_path): + """_do_save() returns error message when process communication fails.""" + player = MagicMock() + player.name = "tester" + session = IFSession(player, "/path/to/zork.z5", "zork") + session._data_dir = tmp_path + + # Mock process that raises an exception + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock(side_effect=BrokenPipeError("process died")) + session.process = mock_process + + result = await session._do_save() + + assert "error" in result.lower() + assert "save" in result.lower() or "failed" in result.lower() + + +@pytest.mark.asyncio +async def test_do_save_handles_stdout_read_failure(tmp_path): + """_do_save() returns error message when reading response fails.""" + player = MagicMock() + player.name = "tester" + session = IFSession(player, "/path/to/zork.z5", "zork") + session._data_dir = tmp_path + + # Mock process where writing succeeds but reading fails + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout = AsyncMock() + mock_process.stdout.read = AsyncMock(side_effect=OSError("read failed")) + session.process = mock_process + + result = await session._do_save() + + assert "error" in result.lower() + + +@pytest.mark.asyncio +async def test_do_restore_handles_process_communication_failure(tmp_path): + """_do_restore() returns empty string when process communication fails.""" + player = MagicMock() + player.name = "tester" + session = IFSession(player, "/path/to/zork.z5", "zork") + session._data_dir = tmp_path + + # Create a save file so we get past the existence check + session._ensure_save_dir() + session.save_path.write_text("fake save data") + + # Mock process that raises an exception + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock(side_effect=BrokenPipeError("process died")) + session.process = mock_process + + result = await session._do_restore() + + # Should return empty string on failure (player starts fresh) + assert result == "" + + +@pytest.mark.asyncio +async def test_do_restore_handles_stdout_read_failure(tmp_path): + """_do_restore() returns empty string when reading response fails.""" + player = MagicMock() + player.name = "tester" + session = IFSession(player, "/path/to/zork.z5", "zork") + session._data_dir = tmp_path + + # Create a save file + session._ensure_save_dir() + session.save_path.write_text("fake save data") + + # Mock process where writing succeeds but reading fails + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout = AsyncMock() + mock_process.stdout.read = AsyncMock(side_effect=OSError("read failed")) + session.process = mock_process + + result = await session._do_restore() + + # Should return empty string on failure (player starts fresh) + assert result == ""