Add error handling for process communication failures in IF save/restore

This commit is contained in:
Jared Miller 2026-02-09 16:45:12 -05:00
parent bb1f9bbbd8
commit 76488139c8
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 128 additions and 28 deletions

View file

@ -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."""

View file

@ -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 == ""