Move z-machine restore before interpreter thread start

Replaces the async _do_restore() (called after thread launch) with a
synchronous _try_restore() called before the thread starts. This
eliminates the race condition where restore mutates z-machine state
while the interpreter thread is running.

The restore prefix message is now part of start()'s return value
instead of being sent separately in play.py.
This commit is contained in:
Jared Miller 2026-02-10 11:51:45 -05:00
parent 224c1f0645
commit 15e1d807aa
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 126 additions and 67 deletions

View file

@ -91,18 +91,9 @@ async def cmd_play(player: Player, args: str) -> None:
await player.send("(type ::help for escape commands)\r\n") await player.send("(type ::help for escape commands)\r\n")
# Check for saved game (both session types now support _do_restore) if intro:
if hasattr(session, "_do_restore") and session.save_path.exists():
await player.send("restoring saved game...\r\n")
restored_text = await session._do_restore()
if restored_text:
await player.send(restored_text + "\r\n")
# Broadcast restored text to spectators
spectator_msg = f"[{player.name}'s terminal]\r\n{restored_text}\r\n"
await broadcast_to_spectators(player, spectator_msg)
elif intro:
await player.send(intro + "\r\n") await player.send(intro + "\r\n")
# Broadcast intro to spectators # Broadcast to spectators
spectator_msg = f"[{player.name}'s terminal]\r\n{intro}\r\n" spectator_msg = f"[{player.name}'s terminal]\r\n{intro}\r\n"
await broadcast_to_spectators(player, spectator_msg) await broadcast_to_spectators(player, spectator_msg)

View file

@ -40,15 +40,38 @@ class EmbeddedIFSession:
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name) safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name)
return self._data_dir / "if_saves" / safe_name / f"{self.game_name}.qzl" return self._data_dir / "if_saves" / safe_name / f"{self.game_name}.qzl"
def _try_restore(self) -> bool:
"""Try to restore from save file before interpreter starts.
Must be called before the interpreter thread is launched.
Returns True if state was restored successfully.
"""
if not self.save_path.exists():
return False
try:
save_data = self.save_path.read_bytes()
parser = QuetzalParser(self._zmachine)
parser.load_from_bytes(save_data)
return True
except Exception as e:
logger.debug(f"Restore failed: {e}")
return False
async def start(self) -> str: async def start(self) -> str:
"""Start the z-machine interpreter, restoring from save if available."""
restored = self._try_restore()
self._thread = threading.Thread(target=self._run_interpreter, daemon=True) self._thread = threading.Thread(target=self._run_interpreter, daemon=True)
self._thread.start() self._thread.start()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._keyboard._waiting.wait) await loop.run_in_executor(None, self._keyboard._waiting.wait)
intro = self._screen.flush() output = self._screen.flush()
return intro if restored:
prefix = "restoring saved game...\r\nrestored."
return f"{prefix}\r\n\r\n{output}" if output else prefix
return output
async def handle_input(self, text: str) -> IFResponse: async def handle_input(self, text: str) -> IFResponse:
if text.lower() == "::quit": if text.lower() == "::quit":
@ -112,34 +135,6 @@ class EmbeddedIFSession:
except Exception as e: except Exception as e:
return f"error: save failed ({e})" return f"error: save failed ({e})"
async def _do_restore(self) -> str:
"""Restore game state from disk. Returns status message."""
if not self.save_path.exists():
return ""
try:
save_data = self.save_path.read_bytes()
parser = QuetzalParser(self._zmachine)
parser.load_from_bytes(save_data)
# Flush stale intro text from screen buffer
self._screen.flush()
# Feed a blank line to continue execution from restored state
self._keyboard._waiting.clear()
self._keyboard.feed("")
# Wait for interpreter to process and generate output
loop = asyncio.get_running_loop()
def wait_for_output():
while not self._done and not self._keyboard._waiting.is_set():
self._keyboard._waiting.wait(timeout=0.1)
await loop.run_in_executor(None, wait_for_output)
# Get the output from restored state
output = self._screen.flush()
return f"restored.\r\n{output}" if output else "restored."
except Exception as e:
logger.debug(f"Restore failed: {e}")
return ""
def get_location_name(self) -> str | None: def get_location_name(self) -> str | None:
try: try:
location_obj = self._zmachine._mem.read_global(0) location_obj = self._zmachine._mem.read_global(0)

View file

@ -92,6 +92,10 @@ async def test_embedded_session_start():
player = Player(name="tester", x=5, y=5, writer=mock_writer) player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1") session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Clean up any existing save to get a fresh start
if session.save_path.exists():
session.save_path.unlink()
intro = await session.start() intro = await session.start()
assert intro is not None assert intro is not None
@ -111,6 +115,10 @@ async def test_embedded_session_handle_input():
player = Player(name="tester", x=5, y=5, writer=mock_writer) player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1") session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Clean up any existing save to get a fresh start
if session.save_path.exists():
session.save_path.unlink()
await session.start() await session.start()
response = await session.handle_input("look") response = await session.handle_input("look")
@ -204,10 +212,83 @@ async def test_embedded_session_room_objects():
assert len(objects) >= 0 # May or may not have visible objects initially assert len(objects) >= 0 # May or may not have visible objects initially
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_try_restore_before_thread():
"""_try_restore() is called synchronously before interpreter thread starts."""
from unittest.mock import patch
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Create a save file
if session.save_path.exists():
session.save_path.unlink()
session.save_path.parent.mkdir(parents=True, exist_ok=True)
# Write a minimal valid save (header only, won't actually restore correctly)
session.save_path.write_bytes(b"FORM\x00\x00\x00\x08IFZSQUTZ\x00\x00\x00\x00")
call_order = []
original_try_restore = session._try_restore
original_run_interpreter = session._run_interpreter
def track_try_restore():
call_order.append("try_restore")
return original_try_restore()
def track_run_interpreter():
call_order.append("run_interpreter")
original_run_interpreter()
with (
patch.object(session, "_try_restore", side_effect=track_try_restore),
patch.object(session, "_run_interpreter", side_effect=track_run_interpreter),
):
await session.start()
# Verify _try_restore was called before _run_interpreter
assert call_order[0] == "try_restore"
assert call_order[1] == "run_interpreter"
await session.stop()
if session.save_path.exists():
session.save_path.unlink()
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_no_restore_without_save():
"""start() does not restore when no save file exists."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="nosaveplayer", writer=mock_writer, x=0, y=0)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Ensure no save file exists
if session.save_path.exists():
session.save_path.unlink()
intro = await session.start()
# Should NOT contain restore message
assert "restoring" not in intro.lower()
# Should contain normal game intro
assert "ZORK" in intro or "West of House" in intro
await session.stop()
@requires_zork @requires_zork
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_embedded_session_save_and_restore(): async def test_embedded_session_save_and_restore():
"""Save a game, create new session, restore it.""" """Save a game, create new session, restore it via start()."""
from mudlib.embedded_if_session import EmbeddedIFSession from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player from mudlib.player import Player
@ -215,6 +296,10 @@ async def test_embedded_session_save_and_restore():
# Start first session # Start first session
player = Player(name="testplayer", writer=mock_writer, x=0, y=0) player = Player(name="testplayer", writer=mock_writer, x=0, y=0)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1") session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Clean up any existing save to get a fresh start
if session.save_path.exists():
session.save_path.unlink()
await session.start() await session.start()
# Do something to change state # Do something to change state
@ -225,11 +310,13 @@ async def test_embedded_session_save_and_restore():
assert "saved" in save_result.output.lower() assert "saved" in save_result.output.lower()
await session.stop() await session.stop()
# Start new session - should auto-restore via play.py pattern # Start new session - should auto-restore via start()
# start() calls _try_restore() BEFORE launching the interpreter thread
session2 = EmbeddedIFSession(player, str(ZORK_PATH), "zork1") session2 = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
await session2.start() intro = await session2.start()
result = await session2._do_restore() # Should contain restore message prefixed to output
assert result # Should have restored assert "restoring saved game" in intro.lower()
assert "restored" in intro.lower()
# The game state should reflect the restored state # The game state should reflect the restored state
# (location may differ after restore, just verify it works) # (location may differ after restore, just verify it works)

View file

@ -131,19 +131,16 @@ async def test_play_handles_dfrotz_missing(player):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_restores_save_if_exists(player): async def test_play_restores_save_if_exists(player):
"""Playing restores saved game if save file exists.""" """Playing restores saved game if save file exists (via start())."""
from pathlib import Path
from mudlib.commands.play import cmd_play from mudlib.commands.play import cmd_play
# Mock IFSession # Mock IFSession - restore now happens in start() before thread launches
mock_session = Mock() mock_session = Mock()
mock_session.start = AsyncMock(return_value="Welcome to Zork!") restored_output = (
mock_session._do_restore = AsyncMock( "restoring saved game...\r\nrestored.\r\n\r\n"
return_value="West of House\nYou are standing in an open field." "West of House\nYou are standing in an open field."
) )
mock_session.save_path = Mock(spec=Path) mock_session.start = AsyncMock(return_value=restored_output)
mock_session.save_path.exists = Mock(return_value=True)
with patch("mudlib.commands.play.IFSession") as MockIFSession: with patch("mudlib.commands.play.IFSession") as MockIFSession:
MockIFSession.return_value = mock_session MockIFSession.return_value = mock_session
@ -154,19 +151,15 @@ async def test_play_restores_save_if_exists(player):
await cmd_play(player, "zork1") await cmd_play(player, "zork1")
# Verify restore was called
mock_session._do_restore.assert_called_once()
# Verify session was created and started # Verify session was created and started
mock_session.start.assert_called_once() mock_session.start.assert_called_once()
# Verify mode was pushed # Verify mode was pushed
assert "if" in player.mode_stack assert "if" in player.mode_stack
# Verify restored text was sent # Verify restored text was sent (start() returns full output with restore)
calls = [call[0][0] for call in player.writer.write.call_args_list] calls = [call[0][0] for call in player.writer.write.call_args_list]
full_output = "".join(calls) full_output = "".join(calls)
assert "restoring" in full_output.lower()
assert "West of House" in full_output assert "West of House" in full_output
assert "open field" in full_output assert "open field" in full_output
@ -174,16 +167,12 @@ async def test_play_restores_save_if_exists(player):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_no_restore_if_no_save(player): async def test_play_no_restore_if_no_save(player):
"""Playing does not restore if no save file exists.""" """Playing does not restore if no save file exists."""
from pathlib import Path
from mudlib.commands.play import cmd_play from mudlib.commands.play import cmd_play
# Mock IFSession # Mock IFSession
mock_session = Mock() mock_session = Mock()
mock_session.start = AsyncMock(return_value="Welcome to Zork!") mock_session.start = AsyncMock(return_value="Welcome to Zork!")
mock_session._do_restore = AsyncMock(return_value="")
mock_session.save_path = Mock(spec=Path)
mock_session.save_path.exists = Mock(return_value=False)
with patch("mudlib.commands.play.IFSession") as MockIFSession: with patch("mudlib.commands.play.IFSession") as MockIFSession:
MockIFSession.return_value = mock_session MockIFSession.return_value = mock_session
@ -194,9 +183,6 @@ async def test_play_no_restore_if_no_save(player):
await cmd_play(player, "zork1") await cmd_play(player, "zork1")
# Verify restore was NOT called
mock_session._do_restore.assert_not_called()
# Verify session was created and started # Verify session was created and started
mock_session.start.assert_called_once() mock_session.start.assert_called_once()