Mayug Maniparambil Claude Opus 4.8 (1M context) commited on
Commit
f227b96
·
1 Parent(s): c52f47a

Isolate native loopy verifier in subprocess; harden submit handler

Browse files

Loopy submissions were returning HTTP 500 (an empty red error box in the
UI) on the live Space. The native RLP loopy C library can segfault/hang
when its puzzle objects are reused across many requests in a long-lived
server, crashing the web worker. Because the crash happened inside
verify() before record_submission(), the attempt was never logged
(submission_count stayed 0, submitted_at null), so solved boards silently
vanished.

- Route loopy through the same short-lived-subprocess pattern already used
for undead (generalized into _verify_rlp_with_subprocess, 20s timeout).
A native crash/hang now kills only the child and returns "not solved".
- Force SDL dummy drivers so pygame audio/video probing no longer leaks
into the verification payload.
- Harden submit_session: normalization/verification failures are recorded
as a failed attempt with an error field instead of surfacing as a 500.
- Fix IndexError in normalize_loopy_board on misshapen/compact boards by
padding the grid to the puzzle's dimensions before edge lookups.
- Add regression tests for subprocess routing, loopy timeout, and
misshapen-board normalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

space_app/board_utils.py CHANGED
@@ -79,6 +79,17 @@ def normalize_loopy_board(problem_ascii: str, board_ascii: str) -> str:
79
  if not grid:
80
  grid = [list(line) for line in pad_lines(puzzle_lines, width)]
81
 
 
 
 
 
 
 
 
 
 
 
 
82
  for row, col in horizontal_edges:
83
  current = grid[row][col]
84
  if current != "-":
 
79
  if not grid:
80
  grid = [list(line) for line in pad_lines(puzzle_lines, width)]
81
 
82
+ # A submitted board with the wrong number of rows/columns must not cause an
83
+ # IndexError below; pad to the decorated puzzle's dimensions so edge lookups
84
+ # always land inside the grid (a too-small/misshapen board simply stays blank
85
+ # in the missing cells and verifies as not solved).
86
+ height = len(puzzle_lines)
87
+ while len(grid) < height:
88
+ grid.append([])
89
+ for grid_row in grid:
90
+ if len(grid_row) < width:
91
+ grid_row.extend(" " * (width - len(grid_row)))
92
+
93
  for row, col in horizontal_edges:
94
  current = grid[row][col]
95
  if current != "-":
space_app/main.py CHANGED
@@ -222,17 +222,32 @@ def create_app(
222
  if not session["started_at"]:
223
  raise HTTPException(status_code=409, detail="Session is not ready yet.")
224
  row = current_dataset_store.get_row(str(session["puzzle_filename"]))
225
- normalized_board_ascii = normalize_board_for_submission(
226
- puzzle_type=str(session["puzzle_type"]),
227
- problem_ascii=row.problem,
228
- board_ascii=request.board_ascii,
229
- )
230
- verification = current_verifier.verify(
231
- puzzle_type=str(session["puzzle_type"]),
232
- problem_ascii=row.problem,
233
- board_ascii=normalized_board_ascii,
234
- args=str(session["args"]),
235
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  updated = current_session_store.record_submission(
237
  session_id=session_id,
238
  solved=bool(verification.get("correct")),
 
222
  if not session["started_at"]:
223
  raise HTTPException(status_code=409, detail="Session is not ready yet.")
224
  row = current_dataset_store.get_row(str(session["puzzle_filename"]))
225
+ # Normalization and verification must never crash the request: a failure
226
+ # here (e.g. a native verifier segfault/hang, or a malformed board) is
227
+ # recorded as a failed attempt rather than surfacing as an opaque 500.
228
+ try:
229
+ normalized_board_ascii = normalize_board_for_submission(
230
+ puzzle_type=str(session["puzzle_type"]),
231
+ problem_ascii=row.problem,
232
+ board_ascii=request.board_ascii,
233
+ )
234
+ except Exception:
235
+ normalized_board_ascii = request.board_ascii
236
+ try:
237
+ verification = current_verifier.verify(
238
+ puzzle_type=str(session["puzzle_type"]),
239
+ problem_ascii=row.problem,
240
+ board_ascii=normalized_board_ascii,
241
+ args=str(session["args"]),
242
+ )
243
+ except Exception as exc: # pragma: no cover - defensive guard
244
+ verification = {
245
+ "board_exists": bool(normalized_board_ascii),
246
+ "board_valid": False,
247
+ "board_modified": False,
248
+ "correct": False,
249
+ "error": f"Verification failed: {exc}"[:500],
250
+ }
251
  updated = current_session_store.record_submission(
252
  session_id=session_id,
253
  solved=bool(verification.get("correct")),
space_app/verification.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import subprocess
4
  import sys
5
  from pathlib import Path
@@ -19,10 +20,29 @@ from evals_verifier import ( # type: ignore
19
  from puzzles import PUZZLES # type: ignore
20
 
21
 
 
 
 
 
 
 
 
 
 
 
22
  class VerificationService:
23
- def _verify_undead_with_timeout(
 
 
 
 
 
 
 
 
24
  self,
25
  *,
 
26
  board_ascii: str,
27
  args: str,
28
  ) -> dict[str, Any]:
@@ -35,13 +55,18 @@ class VerificationService:
35
  if not board_ascii:
36
  return result
37
 
38
- result["board_valid"] = bool(check_undead_structural_validity(board_ascii))
 
 
39
  if not result["board_valid"]:
40
  result["board_modified"] = True
41
  return result
42
 
43
  verifier_path = ROOT_DIR / "submodules" / "rlp" / "verifier.py"
44
- command = [sys.executable, str(verifier_path), "undead", "--arg", args]
 
 
 
45
  try:
46
  completed = subprocess.run(
47
  command,
@@ -49,11 +74,12 @@ class VerificationService:
49
  text=True,
50
  capture_output=True,
51
  cwd=ROOT_DIR,
52
- timeout=15,
53
  check=False,
 
54
  )
55
  except subprocess.TimeoutExpired:
56
- result["error"] = "Undead verification timed out."
57
  return result
58
 
59
  result["correct"] = completed.returncode == 0 and "SOLVED" in completed.stdout
@@ -72,8 +98,9 @@ class VerificationService:
72
  spec = PUZZLES[puzzle_type]
73
  if spec.verifier_type == "flow_free":
74
  return verify_flow_free(problem_ascii, board_ascii)
75
- if spec.verifier_type == "undead":
76
- return self._verify_undead_with_timeout(
 
77
  board_ascii=board_ascii,
78
  args=args,
79
  )
 
1
  from __future__ import annotations
2
 
3
+ import os
4
  import subprocess
5
  import sys
6
  from pathlib import Path
 
20
  from puzzles import PUZZLES # type: ignore
21
 
22
 
23
+ # Puzzle families whose "solved" check runs in the native RLP C library. That
24
+ # library can segfault or hang when its puzzle objects are reused across many
25
+ # requests in a long-lived server process, which crashes the web worker and
26
+ # surfaces as an opaque HTTP 500 (and, sometimes, a hung request). We run those
27
+ # checks in a short-lived subprocess so a native crash/hang kills only the child
28
+ # and we return a clean "not solved" result instead of taking down the worker.
29
+ SUBPROCESS_RLP_PUZZLES = {"undead", "loopy"}
30
+ RLP_VERIFIER_TIMEOUT_SECONDS = 20
31
+
32
+
33
  class VerificationService:
34
+ def _structural_validity(self, *, puzzle_type: str, board_ascii: str) -> bool:
35
+ # Pure-Python structural pre-checks are safe to run in-process. Puzzle
36
+ # families without one are treated as structurally valid here; the
37
+ # subprocess solved-check is the source of truth for correctness.
38
+ if puzzle_type == "undead":
39
+ return bool(check_undead_structural_validity(board_ascii))
40
+ return True
41
+
42
+ def _verify_rlp_with_subprocess(
43
  self,
44
  *,
45
+ puzzle_type: str,
46
  board_ascii: str,
47
  args: str,
48
  ) -> dict[str, Any]:
 
55
  if not board_ascii:
56
  return result
57
 
58
+ result["board_valid"] = self._structural_validity(
59
+ puzzle_type=puzzle_type, board_ascii=board_ascii
60
+ )
61
  if not result["board_valid"]:
62
  result["board_modified"] = True
63
  return result
64
 
65
  verifier_path = ROOT_DIR / "submodules" / "rlp" / "verifier.py"
66
+ command = [sys.executable, str(verifier_path), puzzle_type, "--arg", args]
67
+ # The RLP libraries pull in pygame, which noisily probes audio/video on
68
+ # import; force the dummy drivers so a headless verifier run stays silent.
69
+ env = {**os.environ, "SDL_AUDIODRIVER": "dummy", "SDL_VIDEODRIVER": "dummy"}
70
  try:
71
  completed = subprocess.run(
72
  command,
 
74
  text=True,
75
  capture_output=True,
76
  cwd=ROOT_DIR,
77
+ timeout=RLP_VERIFIER_TIMEOUT_SECONDS,
78
  check=False,
79
+ env=env,
80
  )
81
  except subprocess.TimeoutExpired:
82
+ result["error"] = f"{puzzle_type.capitalize()} verification timed out."
83
  return result
84
 
85
  result["correct"] = completed.returncode == 0 and "SOLVED" in completed.stdout
 
98
  spec = PUZZLES[puzzle_type]
99
  if spec.verifier_type == "flow_free":
100
  return verify_flow_free(problem_ascii, board_ascii)
101
+ if spec.verifier_type in SUBPROCESS_RLP_PUZZLES:
102
+ return self._verify_rlp_with_subprocess(
103
+ puzzle_type=spec.verifier_type,
104
  board_ascii=board_ascii,
105
  args=args,
106
  )
tests/test_core.py CHANGED
@@ -111,6 +111,36 @@ class CoreTests(unittest.TestCase):
111
  self.assertEqual(len(redisplayed.splitlines()), 13)
112
  self.assertIn("+", redisplayed)
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  def test_pattern_normalization_makes_unclicked_cells_white(self) -> None:
115
  board = "\n".join(
116
  [
 
111
  self.assertEqual(len(redisplayed.splitlines()), 13)
112
  self.assertIn("+", redisplayed)
113
 
114
+ def test_loopy_normalization_handles_misshapen_board_without_crashing(self) -> None:
115
+ # A submitted board with the wrong number of rows/columns (e.g. a
116
+ # border-less compact grid) must not raise IndexError during
117
+ # normalization; it should normalize to the expected shape instead.
118
+ problem = "\n".join(
119
+ [
120
+ "+++++++++++++",
121
+ "+ +",
122
+ "+ 3 3 +",
123
+ "+ +",
124
+ "+ 2 1 0 1 2 +",
125
+ "+ +",
126
+ "+ 2 1 0 +",
127
+ "+ +",
128
+ "+ 3 +",
129
+ "+ +",
130
+ "+ 2 2 +",
131
+ "+ +",
132
+ "+++++++++++++",
133
+ ]
134
+ )
135
+ misshapen = "\n".join(["x x x", "| | |", "x x x"]) # only 3x5, far too small
136
+ normalized = normalize_board_for_submission(
137
+ puzzle_type="loopy",
138
+ problem_ascii=problem,
139
+ board_ascii=misshapen,
140
+ )
141
+ self.assertEqual(len(normalized.splitlines()), 11)
142
+ self.assertTrue(all(len(line) == 11 for line in normalized.splitlines()))
143
+
144
  def test_pattern_normalization_makes_unclicked_cells_white(self) -> None:
145
  board = "\n".join(
146
  [
tests/test_verification.py CHANGED
@@ -77,3 +77,71 @@ class VerificationTests(unittest.TestCase):
77
  state = parse_ascii_pattern(board)
78
  self.assertEqual(state["common"]["rowlen"][:5], [1, 1, 1, 2, 1])
79
  self.assertEqual(state["common"]["rowdata"][:6], [1, 2, 3, 1, 3, 3])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  state = parse_ascii_pattern(board)
78
  self.assertEqual(state["common"]["rowlen"][:5], [1, 1, 1, 2, 1])
79
  self.assertEqual(state["common"]["rowdata"][:6], [1, 2, 3, 1, 3, 3])
80
+
81
+ def test_loopy_runs_in_isolated_subprocess(self) -> None:
82
+ # Loopy verification must go through the subprocess path so a native
83
+ # crash/hang in the RLP C library cannot take down the web worker.
84
+ verifier = VerificationService()
85
+ problem = "\n".join(
86
+ [
87
+ "+++++++++++++",
88
+ "+ +",
89
+ "+ 3 3 +",
90
+ "+ +",
91
+ "+ 2 1 0 1 2 +",
92
+ "+ +",
93
+ "+ 2 1 0 +",
94
+ "+ +",
95
+ "+ 3 +",
96
+ "+ +",
97
+ "+ 2 2 +",
98
+ "+ +",
99
+ "+++++++++++++",
100
+ ]
101
+ )
102
+ solution = "\n".join(
103
+ [
104
+ " - x - x x ",
105
+ "|3| |3| x x",
106
+ " x - x - - ",
107
+ "|2x1x0x1x2|",
108
+ " - x x x x ",
109
+ "x2| x1x0x |",
110
+ " x x - x - ",
111
+ "x | | | |3x",
112
+ " - x x x - ",
113
+ "| x2| |2x |",
114
+ " - - x - -",
115
+ ]
116
+ )
117
+ with patch(
118
+ "space_app.verification.subprocess.run", wraps=subprocess.run
119
+ ) as run_spy:
120
+ try:
121
+ result = verifier.verify(
122
+ puzzle_type="loopy",
123
+ problem_ascii=problem,
124
+ board_ascii=solution,
125
+ args="5x5de",
126
+ )
127
+ except BaseException as caught: # pragma: no cover - native best effort
128
+ self.skipTest(f"Native verifier unavailable locally: {caught}")
129
+ return
130
+ run_spy.assert_called_once()
131
+ self.assertTrue(result["correct"])
132
+
133
+ def test_loopy_verification_timeout_returns_clean_error(self) -> None:
134
+ verifier = VerificationService()
135
+ solution = " - x - x x \n|3| |3| x x"
136
+ with patch(
137
+ "space_app.verification.subprocess.run",
138
+ side_effect=subprocess.TimeoutExpired("cmd", 20),
139
+ ):
140
+ result = verifier.verify(
141
+ puzzle_type="loopy",
142
+ problem_ascii=solution,
143
+ board_ascii=solution,
144
+ args="5x5de",
145
+ )
146
+ self.assertFalse(result["correct"])
147
+ self.assertEqual(result.get("error"), "Loopy verification timed out.")