Skip to content

Commit 050cdca

Browse files
aadityansha06Shashank-Tripathi-07
authored andcommitted
fix(labs): stop silently dropping WASM DesignLedger saves (#1985)
save() previously fire-and-forgot the WASM persistence task via asyncio.create_task(), so IndexedDB failures were swallowed and students could lose progress with no indication anything went wrong. - save_async() now raises on IndexedDB failure instead of print()-ing - save() attaches a done-callback to the background task and records failures on last_save_error / save_pending - added asave() and flush() for callers that can await - added regression tests in tests/test_state.py
1 parent 5964e31 commit 050cdca

2 files changed

Lines changed: 232 additions & 44 deletions

File tree

mlsysim/mlsysim/labs/state.py

Lines changed: 133 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,25 @@ def __init__(self):
2626
self.config_dir = Path.home() / ".mlsys"
2727
self.file_path = self.config_dir / "ledger.json"
2828
self._state = LedgerState()
29+
self._pending_save_task = None
30+
self._last_save_error: Optional[str] = None
2931
self.load()
3032

33+
@property
34+
def last_save_error(self) -> Optional[str]:
35+
"""Error message from the most recent failed background save, if any.
36+
37+
Callers in WASM environments should check this after calling
38+
``save()`` (e.g. on the next reactive cell run) since ``save()``
39+
cannot block on the result inside a synchronous notebook cell.
40+
"""
41+
return self._last_save_error
42+
43+
@property
44+
def save_pending(self) -> bool:
45+
"""True while a WASM background save has not finished yet."""
46+
return self._pending_save_task is not None and not self._pending_save_task.done()
47+
3148
@property
3249
def is_wasm(self) -> bool:
3350
"""Detect if we are running in a browser environment (Pyodide)."""
@@ -110,53 +127,62 @@ async def load_async(self) -> LedgerState:
110127
return self._state
111128

112129
async def save_async(self):
113-
"""Async save for WASM environments using IndexedDB."""
130+
"""Async save for WASM environments using IndexedDB.
131+
132+
Unlike the previous implementation, this raises on failure instead
133+
of printing to the console and returning ``None``. Callers that can
134+
await (e.g. :meth:`asave`, tests) will see the real exception.
135+
Callers that cannot await (the synchronous :meth:`save`) capture the
136+
exception via a done-callback on the background task instead.
137+
"""
114138
if not self.is_wasm:
115-
return
116-
try:
117-
import json
118-
from pyodide.code import run_js
119-
from js import globalThis
120-
121-
state_json = json.dumps(asdict(self._state))
122-
globalThis.__mlsys_temp_state = state_json
123-
124-
js_code = """
125-
(async () => {
126-
return new Promise((resolve, reject) => {
127-
const request = indexedDB.open("mlsys_ledger_db", 1);
128-
request.onupgradeneeded = (e) => {
129-
const db = e.target.result;
130-
if (!db.objectStoreNames.contains("ledger")) {
131-
db.createObjectStore("ledger");
132-
}
133-
};
134-
request.onsuccess = (e) => {
135-
const db = e.target.result;
136-
try {
137-
const tx = db.transaction("ledger", "readwrite");
138-
const store = tx.objectStore("ledger");
139-
const putReq = store.put(globalThis.__mlsys_temp_state, "mlsys_design_ledger");
140-
putReq.onsuccess = () => resolve(true);
141-
putReq.onerror = () => resolve(false);
142-
} catch (err) {
143-
resolve(false);
144-
}
145-
};
146-
request.onerror = () => resolve(false);
147-
});
148-
})()
149-
"""
150-
await run_js(js_code)
151-
except Exception as e:
152-
print(f"Failed to save to IndexedDB: {e}")
139+
return True
153140

154-
def save(self, track: str = None, step: int = None, design: dict = None, chapter: int = None):
155-
"""Persists the design decisions to storage.
141+
import json
142+
from pyodide.code import run_js
143+
from js import globalThis
156144

157-
``chapter`` is kept as a compatibility alias for existing Co-Labs,
158-
while ``step`` is the newer generic ledger key.
145+
state_json = json.dumps(asdict(self._state))
146+
globalThis.__mlsys_temp_state = state_json
147+
148+
js_code = """
149+
(async () => {
150+
return new Promise((resolve, reject) => {
151+
const request = indexedDB.open("mlsys_ledger_db", 1);
152+
request.onupgradeneeded = (e) => {
153+
const db = e.target.result;
154+
if (!db.objectStoreNames.contains("ledger")) {
155+
db.createObjectStore("ledger");
156+
}
157+
};
158+
request.onsuccess = (e) => {
159+
const db = e.target.result;
160+
try {
161+
const tx = db.transaction("ledger", "readwrite");
162+
const store = tx.objectStore("ledger");
163+
const putReq = store.put(globalThis.__mlsys_temp_state, "mlsys_design_ledger");
164+
putReq.onsuccess = () => resolve(true);
165+
putReq.onerror = () => reject(new Error(
166+
"IndexedDB put failed: " + (putReq.error ? putReq.error.message : "unknown error")
167+
));
168+
} catch (err) {
169+
reject(new Error("IndexedDB transaction failed: " + err.message));
170+
}
171+
};
172+
request.onerror = () => reject(new Error(
173+
"indexedDB.open failed: " + (request.error ? request.error.message : "unknown error")
174+
+ " (storage may be disabled, full, or unavailable in private browsing)"
175+
));
176+
});
177+
})()
159178
"""
179+
# If the JS promise rejects, Pyodide raises the corresponding
180+
# exception here rather than us having to poll a boolean result.
181+
await run_js(js_code)
182+
return True
183+
184+
def _apply_pending_state(self, track, step, design, chapter):
185+
"""Shared bookkeeping used by both save() and asave()."""
160186
if track:
161187
self._state.track = track
162188

@@ -169,14 +195,77 @@ def save(self, track: str = None, step: int = None, design: dict = None, chapter
169195
if design is not None:
170196
self._state.history[step_id] = design
171197

198+
def _on_save_task_done(self, task):
199+
"""Done-callback for the background WASM save task.
200+
201+
This is what actually fixes the silent-failure bug: instead of the
202+
exception from `save_async()` disappearing into an unobserved task,
203+
we record it and log it loudly so it's impossible to miss in the
204+
browser console, and expose it via `last_save_error` /
205+
`save_pending` so the notebook UI can surface it to the student.
206+
"""
207+
try:
208+
task.result()
209+
self._last_save_error = None
210+
except Exception as e:
211+
self._last_save_error = str(e)
212+
message = f"[DesignLedger] SAVE FAILED - progress was NOT persisted: {e}"
213+
try:
214+
from js import console
215+
console.error(message)
216+
except Exception:
217+
print(message, file=sys.stderr)
218+
219+
def save(self, track: str = None, step: int = None, design: dict = None, chapter: int = None):
220+
"""Persists the design decisions to storage.
221+
222+
``chapter`` is kept as a compatibility alias for existing Co-Labs,
223+
while ``step`` is the newer generic ledger key.
224+
225+
In WASM this schedules a background save (marimo cells are
226+
synchronous, so we can't await here), but unlike the previous
227+
implementation the resulting task's outcome is observed: failures
228+
are logged loudly and recorded on ``self.last_save_error`` instead
229+
of vanishing silently. Callers that need a hard persistence
230+
guarantee (tests, async code) should use :meth:`asave` instead.
231+
"""
232+
self._apply_pending_state(track, step, design, chapter)
233+
172234
if self.is_wasm:
173235
import asyncio
174-
asyncio.create_task(self.save_async())
236+
task = asyncio.ensure_future(self.save_async())
237+
task.add_done_callback(self._on_save_task_done)
238+
self._pending_save_task = task
239+
else:
240+
self.config_dir.mkdir(exist_ok=True)
241+
with open(self.file_path, 'w') as f:
242+
json.dump(asdict(self._state), f, indent=2)
243+
244+
async def asave(self, track: str = None, step: int = None, design: dict = None, chapter: int = None):
245+
"""Async variant of :meth:`save` that awaits persistence.
246+
247+
Guarantees the data is written (or raises) before returning. Use
248+
this from async contexts (tests, async marimo cells) whenever you
249+
need certainty that a save actually succeeded.
250+
"""
251+
self._apply_pending_state(track, step, design, chapter)
252+
253+
if self.is_wasm:
254+
await self.save_async()
255+
self._last_save_error = None
175256
else:
176257
self.config_dir.mkdir(exist_ok=True)
177258
with open(self.file_path, 'w') as f:
178259
json.dump(asdict(self._state), f, indent=2)
179260

261+
async def flush(self):
262+
"""Await any in-flight background save scheduled by `save()`.
263+
264+
Raises the underlying exception if that save failed.
265+
"""
266+
if self._pending_save_task is not None:
267+
await self._pending_save_task
268+
180269
def get_design(self, step_id: int) -> Optional[Dict[str, Any]]:
181270
"""Retrieves the design dictionary for a specific step."""
182271
return self._state.history.get(step_id)

mlsysim/tests/test_state.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Tests for DesignLedger persistence (mlsysim/labs/state.py).
2+
3+
Covers the WASM background-save failure path fixed in #1985: previously
4+
`save()` used `asyncio.create_task(...)` fire-and-forget, so IndexedDB
5+
failures inside `save_async()` were silently swallowed and never surfaced
6+
to the caller. See:
7+
https://github.com/harvard-edge/cs249r_book/issues/1985
8+
"""
9+
import asyncio
10+
11+
import pytest
12+
13+
from mlsysim.labs.state import DesignLedger
14+
import mlsysim.labs.state as state_mod
15+
16+
17+
def _ledger_with_home(monkeypatch, tmp_path):
18+
monkeypatch.setattr(state_mod.Path, "home", lambda: tmp_path)
19+
return DesignLedger()
20+
21+
22+
def _force_wasm(monkeypatch, ledger):
23+
monkeypatch.setattr(type(ledger), "is_wasm", property(lambda self: True))
24+
25+
26+
def test_save_and_load_roundtrip_native(tmp_path, monkeypatch):
27+
"""Non-WASM path: save() should persist synchronously to disk."""
28+
ledger = _ledger_with_home(monkeypatch, tmp_path)
29+
ledger.save(track="edge", step=1, design={"foo": "bar"})
30+
31+
reloaded = _ledger_with_home(monkeypatch, tmp_path)
32+
assert reloaded.get_track() == "edge"
33+
assert reloaded.get_design(1) == {"foo": "bar"}
34+
35+
36+
def test_wasm_save_success_clears_error(tmp_path, monkeypatch):
37+
"""A successful WASM background save should leave last_save_error unset."""
38+
ledger = _ledger_with_home(monkeypatch, tmp_path)
39+
_force_wasm(monkeypatch, ledger)
40+
41+
async def fake_save_async(self):
42+
return True
43+
44+
monkeypatch.setattr(DesignLedger, "save_async", fake_save_async)
45+
46+
async def run():
47+
ledger.save(step=1, design={"ok": True})
48+
await ledger.flush()
49+
50+
asyncio.run(run())
51+
assert ledger.last_save_error is None
52+
assert ledger.save_pending is False
53+
54+
55+
def test_wasm_save_failure_is_captured_not_silent(tmp_path, monkeypatch):
56+
"""Regression test for #1985.
57+
58+
Previously: an IndexedDB failure inside save_async() was swallowed by
59+
a fire-and-forget asyncio.create_task(), so save() returned as if it
60+
had succeeded and students never learned their progress was lost.
61+
62+
Now: the failure must be captured on `last_save_error`, and
63+
`save_pending` must go back to False once the background task settles.
64+
"""
65+
ledger = _ledger_with_home(monkeypatch, tmp_path)
66+
_force_wasm(monkeypatch, ledger)
67+
68+
async def failing_save_async(self):
69+
raise RuntimeError("indexedDB.open failed: QuotaExceededError")
70+
71+
monkeypatch.setattr(DesignLedger, "save_async", failing_save_async)
72+
73+
async def run():
74+
ledger.save(step=1, design={"will_fail": True})
75+
with pytest.raises(RuntimeError):
76+
await ledger.flush()
77+
78+
asyncio.run(run())
79+
80+
assert ledger.save_pending is False
81+
assert ledger.last_save_error is not None
82+
assert "QuotaExceededError" in ledger.last_save_error
83+
84+
85+
def test_asave_raises_on_failure_directly(tmp_path, monkeypatch):
86+
"""asave() should propagate the exception directly to an awaiting caller."""
87+
ledger = _ledger_with_home(monkeypatch, tmp_path)
88+
_force_wasm(monkeypatch, ledger)
89+
90+
async def failing_save_async(self):
91+
raise RuntimeError("storage disabled")
92+
93+
monkeypatch.setattr(DesignLedger, "save_async", failing_save_async)
94+
95+
async def run():
96+
with pytest.raises(RuntimeError, match="storage disabled"):
97+
await ledger.asave(step=2, design={"x": 1})
98+
99+
asyncio.run(run())

0 commit comments

Comments
 (0)