Skip to content

Commit a35a655

Browse files
ShivtejG236profvjreddi
authored andcommitted
fix(mlsysim): surface load failures via DesignLedger.last_load_error
DesignLedger.load() and _load_async() swallowed every exception and reset to an empty LedgerState, so a corrupt ledger was indistinguishable from a first run. Record the failure in last_load_error, mirroring the existing last_save_error property. Closes #2007
1 parent ea8fc63 commit a35a655

3 files changed

Lines changed: 253 additions & 7 deletions

File tree

labs/tests/test_wasm_persistence.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@ class body, was silently rewritten by the Python compiler to
3939

4040
import functools
4141
import http.server
42+
import json
4243
import shutil
4344
import socketserver
4445
import threading
4546
from pathlib import Path
4647

4748
import pytest
4849

50+
4951
STATE_PY = (
5052
Path(__file__).resolve().parents[2] / "mlsysim" / "mlsysim" / "labs" / "state.py"
5153
)
@@ -205,3 +207,146 @@ def test_design_ledger_save_async_persists_in_real_indexeddb(served_dir):
205207
f"from #1985 / PR #1988. A mocked test cannot catch this; only a "
206208
f"real Pyodide + IndexedDB check like this one can."
207209
)
210+
211+
212+
def test_load_async_corrupt_record_sets_last_load_error(served_dir):
213+
"""A stored record exists but is corrupt JSON -- json.loads() raising
214+
is a Python-side failure independent of the JS resolve/reject shape,
215+
so last_load_error must be populated regardless of #1988's status."""
216+
from playwright.sync_api import sync_playwright
217+
218+
_, port = served_dir
219+
220+
with sync_playwright() as p:
221+
browser = p.chromium.launch()
222+
context = browser.new_context()
223+
try:
224+
page = context.new_page()
225+
init_errors: list[str] = []
226+
page.on(
227+
"pageerror",
228+
lambda exc, errors=init_errors: errors.append(str(exc)),
229+
)
230+
231+
page.goto(f"http://127.0.0.1:{port}/index.html")
232+
page.wait_for_function(
233+
"window.__ready === true || window.__initError", timeout=30_000
234+
)
235+
init_error = page.evaluate("window.__initError || null")
236+
assert not init_error, f"Pyodide init failed: {init_error}"
237+
assert not init_errors, f"Uncaught page errors during init: {init_errors}"
238+
239+
page.evaluate(
240+
"""
241+
() => new Promise((resolve) => {
242+
const req = indexedDB.deleteDatabase("mlsys_ledger_db");
243+
req.onsuccess = req.onerror = req.onblocked = () => resolve();
244+
})
245+
"""
246+
)
247+
248+
# Seed a corrupt record directly at the storage layer.
249+
page.evaluate(
250+
"""
251+
() => new Promise((resolve, reject) => {
252+
const req = indexedDB.open("mlsys_ledger_db", 1);
253+
req.onupgradeneeded = (e) => {
254+
const db = e.target.result;
255+
if (!db.objectStoreNames.contains("ledger")) {
256+
db.createObjectStore("ledger");
257+
}
258+
};
259+
req.onsuccess = (e) => {
260+
const db = e.target.result;
261+
const tx = db.transaction("ledger", "readwrite");
262+
tx.objectStore("ledger").put("{not valid json", "mlsys_design_ledger");
263+
tx.oncomplete = () => { db.close(); resolve(); };
264+
tx.onerror = () => { db.close(); reject(tx.error); };
265+
};
266+
req.onerror = () => reject(req.error);
267+
})
268+
"""
269+
)
270+
271+
result = page.evaluate(
272+
"""
273+
async () => {
274+
const pyodide = window.__pyodide;
275+
return await pyodide.runPythonAsync(`
276+
import json
277+
ledger = DesignLedger()
278+
await ledger.load_async()
279+
json.dumps({"error": ledger.last_load_error})
280+
`);
281+
}
282+
"""
283+
)
284+
page.close()
285+
finally:
286+
context.close()
287+
browser.close()
288+
289+
parsed = json.loads(result)
290+
assert parsed["error"] is not None, (
291+
"load_async() must surface a corrupt-JSON read failure via "
292+
"last_load_error instead of silently returning a blank LedgerState()."
293+
)
294+
295+
296+
def test_load_async_synchronous_indexeddb_open_throw_sets_last_load_error(served_dir):
297+
"""indexedDB.open() throwing synchronously must reject the Promise
298+
(per the Promise constructor spec) and propagate to last_load_error --
299+
true today even against the pre-#1988 resolve(null)-style onerror
300+
handlers, since this never reaches those handlers at all."""
301+
from playwright.sync_api import sync_playwright
302+
303+
_, port = served_dir
304+
305+
with sync_playwright() as p:
306+
browser = p.chromium.launch()
307+
context = browser.new_context()
308+
try:
309+
page = context.new_page()
310+
init_errors: list[str] = []
311+
page.on(
312+
"pageerror",
313+
lambda exc, errors=init_errors: errors.append(str(exc)),
314+
)
315+
316+
page.goto(f"http://127.0.0.1:{port}/index.html")
317+
page.wait_for_function(
318+
"window.__ready === true || window.__initError", timeout=30_000
319+
)
320+
init_error = page.evaluate("window.__initError || null")
321+
assert not init_error, f"Pyodide init failed: {init_error}"
322+
assert not init_errors, f"Uncaught page errors during init: {init_errors}"
323+
324+
page.evaluate(
325+
"""() => {
326+
window.indexedDB.open = () => {
327+
throw new Error('Simulated synchronous IndexedDB failure');
328+
};
329+
}"""
330+
)
331+
332+
result = page.evaluate(
333+
"""
334+
async () => {
335+
const pyodide = window.__pyodide;
336+
return await pyodide.runPythonAsync(`
337+
import json
338+
ledger = DesignLedger()
339+
await ledger.load_async()
340+
json.dumps({"error": ledger.last_load_error})
341+
`);
342+
}
343+
"""
344+
)
345+
page.close()
346+
finally:
347+
context.close()
348+
browser.close()
349+
350+
parsed = json.loads(result)
351+
assert parsed["error"] is not None
352+
assert "Simulated synchronous IndexedDB failure" in parsed["error"]

mlsysim/mlsysim/labs/state.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(self):
3131
self.file_path = self.config_dir / "ledger.json"
3232

3333
self._state = LedgerState()
34+
self._last_load_error: Optional[str] = None
3435

3536
# WASM save tasks remain tracked until flush() observes them. Keeping
3637
# completed tasks lets a later flush() re-raise persistence failures.
@@ -49,6 +50,11 @@ def last_save_error(self) -> Optional[str]:
4950
"""
5051
return self._last_save_error
5152

53+
@property
54+
def last_load_error(self) -> Optional[str]:
55+
"""Error message from the most recent failed load."""
56+
return self._last_load_error
57+
5258
@property
5359
def save_pending(self) -> bool:
5460
"""True while at least one WASM background save is still running."""
@@ -75,6 +81,7 @@ def _parse_history(self, data: dict) -> dict:
7581

7682
def load(self) -> LedgerState:
7783
"""Loads the ledger from the best available persistent storage."""
84+
self._last_load_error = None
7885

7986
# WASM loading is asynchronous, so synchronous load()
8087
# simply returns the current in-memory state.
@@ -88,10 +95,10 @@ def load(self) -> LedgerState:
8895
data = json.load(f)
8996

9097
data["history"] = self._parse_history(data)
91-
9298
self._state = LedgerState(**data)
9399

94-
except Exception:
100+
except Exception as e:
101+
self._last_load_error = f"{type(e).__name__}: {e}"
95102
self._state = LedgerState()
96103

97104
return self._state
@@ -100,6 +107,7 @@ async def load_async(self) -> LedgerState:
100107
"""
101108
Async load for WASM environments using IndexedDB.
102109
"""
110+
self._last_load_error = None
103111

104112
if not self.is_wasm:
105113
return self.load()
@@ -187,14 +195,12 @@ async def load_async(self) -> LedgerState:
187195

188196
if raw:
189197
data = json.loads(raw)
190-
191198
data["history"] = self._parse_history(data)
192-
193199
self._state = LedgerState(**data)
194200

195201
except Exception as e:
202+
self._last_load_error = f"{type(e).__name__}: {e}"
196203
print(f"Failed to load from IndexedDB: {e}")
197-
198204
self._state = LedgerState()
199205

200206
return self._state

mlsysim/tests/test_state.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""Tests for DesignLedger persistence (mlsysim/labs/state.py).
1+
"""
2+
Tests for DesignLedger persistence (mlsysim/labs/state.py).
23
34
Covers the WASM background-save failure path fixed in #1985: previously
45
`save()` used `asyncio.create_task(...)` fire-and-forget, so IndexedDB
@@ -11,7 +12,10 @@
1112

1213
import pytest
1314

14-
from mlsysim.labs.state import DesignLedger
15+
import json
16+
from pathlib import Path
17+
18+
from mlsysim.labs.state import DesignLedger, LedgerState
1519
import mlsysim.labs.state as state_mod
1620

1721

@@ -122,3 +126,94 @@ async def run():
122126
await ledger.asave(step=2, design={"x": 1})
123127

124128
asyncio.run(run())
129+
130+
131+
"""--- Read-path error handling (#1994) ---
132+
Native/local-filesystem path only. WASM/IndexedDB is covered separately in labs/tests/test_wasm_persistence.py."""
133+
134+
def test_init_does_not_raise(tmp_path, monkeypatch):
135+
"""Regression guard: last_load_error is a read-only @property backed
136+
by _last_load_error. Assigning self.last_load_error = ... anywhere
137+
(including __init__) raises AttributeError immediately, since the
138+
property has no setter. This is the exact bug that would otherwise
139+
only surface at runtime, not at review time."""
140+
_ledger_with_home(monkeypatch, tmp_path) # must not raise
141+
142+
143+
def test_load_missing_file_is_not_an_error(tmp_path, monkeypatch):
144+
"""First run for a student -- no ledger.json exists yet. This is
145+
expected, not a failure, and must not populate last_load_error."""
146+
ledger = _ledger_with_home(monkeypatch, tmp_path)
147+
state = ledger.load()
148+
assert isinstance(state, LedgerState)
149+
assert ledger.last_load_error is None
150+
151+
152+
def test_load_corrupt_file_sets_last_load_error(tmp_path, monkeypatch):
153+
"""A save file exists but isn't valid JSON -- e.g. truncated by a
154+
crash mid-write. Must fail safe (blank state, no crash) but the
155+
failure must be visible via last_load_error, not silently discarded."""
156+
ledger = _ledger_with_home(monkeypatch, tmp_path)
157+
ledger.config_dir.mkdir(exist_ok=True)
158+
ledger.file_path.write_text("{not valid json")
159+
160+
state = ledger.load()
161+
162+
assert isinstance(state, LedgerState)
163+
assert ledger.last_load_error is not None
164+
assert "json" in ledger.last_load_error.lower()
165+
166+
167+
def test_load_corrupt_file_resets_to_blank_state(tmp_path, monkeypatch):
168+
"""A corrupt file must not leave stale/partial in-memory state around
169+
-- the fallback is a fresh LedgerState(), not a half-populated one."""
170+
ledger = _ledger_with_home(monkeypatch, tmp_path)
171+
ledger.config_dir.mkdir(exist_ok=True)
172+
ledger.file_path.write_text("{not valid json")
173+
174+
state = ledger.load()
175+
176+
assert state.track is None
177+
assert state.current_step == 0
178+
assert state.history == {}
179+
180+
181+
def test_load_valid_file_clears_previous_error(tmp_path, monkeypatch):
182+
"""last_load_error must reset on a subsequent successful load --
183+
it's a snapshot of the *most recent* attempt, not sticky forever."""
184+
ledger = _ledger_with_home(monkeypatch, tmp_path)
185+
ledger.config_dir.mkdir(exist_ok=True)
186+
ledger.file_path.write_text("{not valid json")
187+
ledger.load()
188+
assert ledger.last_load_error is not None
189+
190+
ledger.file_path.write_text(json.dumps({
191+
"track": "edge",
192+
"current_step": 3,
193+
"history": {},
194+
"last_updated": "2026-08-10T00:00:00",
195+
}))
196+
state = ledger.load()
197+
198+
assert ledger.last_load_error is None
199+
assert state.track == "edge"
200+
assert state.current_step == 3
201+
202+
203+
def test_load_valid_file_round_trips_history(tmp_path, monkeypatch):
204+
"""Sanity check that the happy path (already-existing behavior)
205+
wasn't broken by the error-handling changes."""
206+
ledger = _ledger_with_home(monkeypatch, tmp_path)
207+
ledger.config_dir.mkdir(exist_ok=True)
208+
ledger.file_path.write_text(json.dumps({
209+
"track": "cloud",
210+
"current_step": 5,
211+
"history": {"1": {"choice": "gpu"}, "5": {"choice": "spot"}},
212+
"last_updated": "2026-08-10T00:00:00",
213+
}))
214+
215+
state = ledger.load()
216+
217+
assert ledger.last_load_error is None
218+
assert state.history[1] == {"choice": "gpu"}
219+
assert state.history[5] == {"choice": "spot"}

0 commit comments

Comments
 (0)