@@ -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 )
0 commit comments