Skip to content

Commit 0da0686

Browse files
committed
Handle Bomberland validation timeouts
1 parent 5da4d43 commit 0da0686

2 files changed

Lines changed: 78 additions & 21 deletions

File tree

codeclash/arenas/bomberland/bomberland.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -71,27 +71,31 @@ def validate_code(self, agent: Player) -> tuple[bool, str | None]:
7171
if syntax_check["returncode"] != 0:
7272
return False, f"Python syntax error in `{self.submission}`:\n{syntax_check['output']}"
7373

74-
import_check = agent.environment.execute(
75-
"python - <<'PY'\n"
76-
"import importlib.util\n"
77-
f"spec = importlib.util.spec_from_file_location('submission_agent', {self.submission!r})\n"
78-
"module = importlib.util.module_from_spec(spec)\n"
79-
"spec.loader.exec_module(module)\n"
80-
"assert hasattr(module, 'next_actions'), 'next_actions callable not found'\n"
81-
"assert callable(module.next_actions), 'next_actions must be callable'\n"
82-
"state = {\n"
83-
" 'connection': {'agent_id': 'Alice'},\n"
84-
" 'agents': {'Alice': {'unit_ids': ['u0']}},\n"
85-
" 'unit_state': {'u0': {'agent_id': 'Alice', 'hp': 3, 'coordinates': [1, 1]}},\n"
86-
" 'entities': [],\n"
87-
" 'world': {'width': 5, 'height': 5},\n"
88-
" 'tick': 0,\n"
89-
"}\n"
90-
"result = module.next_actions(state)\n"
91-
"assert result is None or isinstance(result, dict), 'next_actions must return a dict or None'\n"
92-
"PY",
93-
timeout=int(self._game_arg("validation_timeout")),
94-
)
74+
validation_timeout = int(self._game_arg("validation_timeout"))
75+
try:
76+
import_check = agent.environment.execute(
77+
"python - <<'PY'\n"
78+
"import importlib.util\n"
79+
f"spec = importlib.util.spec_from_file_location('submission_agent', {self.submission!r})\n"
80+
"module = importlib.util.module_from_spec(spec)\n"
81+
"spec.loader.exec_module(module)\n"
82+
"assert hasattr(module, 'next_actions'), 'next_actions callable not found'\n"
83+
"assert callable(module.next_actions), 'next_actions must be callable'\n"
84+
"state = {\n"
85+
" 'connection': {'agent_id': 'Alice'},\n"
86+
" 'agents': {'Alice': {'unit_ids': ['u0']}},\n"
87+
" 'unit_state': {'u0': {'agent_id': 'Alice', 'hp': 3, 'coordinates': [1, 1]}},\n"
88+
" 'entities': [],\n"
89+
" 'world': {'width': 5, 'height': 5},\n"
90+
" 'tick': 0,\n"
91+
"}\n"
92+
"result = module.next_actions(state)\n"
93+
"assert result is None or isinstance(result, dict), 'next_actions must return a dict or None'\n"
94+
"PY",
95+
timeout=validation_timeout,
96+
)
97+
except subprocess.TimeoutExpired:
98+
return False, f"`next_actions` validation exceeded {validation_timeout}s timeout"
9599
if import_check["returncode"] != 0:
96100
return False, f"Could not import or call `next_actions` from `{self.submission}`:\n{import_check['output']}"
97101

@@ -137,6 +141,17 @@ def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
137141
for agent in agents:
138142
stats.scores[agent.name] = CRASH_SCORE
139143
stats.player_stats[agent.name].score = CRASH_SCORE
144+
stats.details.append(
145+
json.dumps(
146+
{
147+
"player": agent.name,
148+
"score": CRASH_SCORE,
149+
"status": "error",
150+
"error": f"missing Bomberland result file: {result_file}",
151+
},
152+
sort_keys=True,
153+
)
154+
)
140155
return
141156

142157
with open(result_file) as f:

tests/arenas/test_bomberland.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib.util
22
import json
3+
import subprocess
34
import time
45
from pathlib import Path
56

@@ -119,6 +120,28 @@ def execute(self, cmd, cwd=None, timeout=None):
119120
assert error is None
120121
assert player.environment.timeouts[-1] == 9
121122

123+
def test_validation_timeout_invalidates_submission(self):
124+
arena = BomberlandArena.__new__(BomberlandArena)
125+
arena.submission = "bomberland_agent.py"
126+
arena.config = {"game": {"name": "Bomberland", "args": {"validation_timeout": 3}}}
127+
128+
class TimeoutEnvironment(MockEnvironment):
129+
def __init__(self):
130+
super().__init__(
131+
files={"bomberland_agent.py": "def next_actions(game_state):\n return {}\n"},
132+
command_outputs={"python -m py_compile bomberland_agent.py": {"output": "", "returncode": 0}},
133+
)
134+
135+
def execute(self, cmd, cwd=None, timeout=None):
136+
if cmd.startswith("python - <<'PY'"):
137+
raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout)
138+
return super().execute(cmd, cwd=cwd, timeout=timeout)
139+
140+
valid, error = arena.validate_code(MockPlayer("Alice", TimeoutEnvironment()))
141+
142+
assert valid is False
143+
assert error == "`next_actions` validation exceeded 3s timeout"
144+
122145

123146
class TestBomberlandResults:
124147
def test_parse_winner(self, tmp_log_dir):
@@ -178,6 +201,25 @@ def test_missing_player_uses_crash_score(self, tmp_log_dir):
178201
assert stats.winner == "Alice"
179202
assert stats.scores == {"Alice": -5.0, "Bob": CRASH_SCORE}
180203

204+
def test_missing_result_file_records_crash_details(self, tmp_log_dir):
205+
arena = BomberlandArena.__new__(BomberlandArena)
206+
arena.log_local = tmp_log_dir
207+
arena.logger = type("Logger", (), {"error": lambda self, msg: None})()
208+
(tmp_log_dir / "rounds" / "1").mkdir(parents=True)
209+
210+
agents = [MockPlayer("Alice"), MockPlayer("Bob")]
211+
stats = RoundStats(round_num=1, agents=agents)
212+
213+
arena.get_results(agents, 1, stats)
214+
215+
assert stats.winner == RESULT_TIE
216+
assert stats.scores == {"Alice": CRASH_SCORE, "Bob": CRASH_SCORE}
217+
assert len(stats.details) == 2
218+
detail = json.loads(stats.details[0])
219+
assert detail["status"] == "error"
220+
assert detail["score"] == CRASH_SCORE
221+
assert "missing Bomberland result file" in detail["error"]
222+
181223

182224
class TestBomberlandExecution:
183225
def test_execute_round_uses_nested_game_args(self):

0 commit comments

Comments
 (0)