fix(labs): stop silently dropping WASM DesignLedger saves (#1985) - #1988
Conversation
|
Hi @Shashank-Tripathi-07, this is ready for your review. Fixes the silent WASM save failure from #1985 by having Ran the full test suite plus the actual CI workflow against this branch — core checks pass. Still finishing the browser-level Pyodide smoke test to fully match the original repro method, will update here once done. Let me know if you'd like anything changed before the @profvjreddi gives his final verdict to merge this pr. |
Shashank-Tripathi-07
left a comment
There was a problem hiding this comment.
Hey @aadityansha06 — really appreciate the thoroughness here (the done-callback approach, last_save_error/save_pending, asave(), flush() are all sensible API additions, and the regression tests are clean). Before this lands I did the thing your PR description says you're still working on — a real browser-level smoke test against real Pyodide + headless Chromium, using the exact reproduction methodology from #1985 — and I think it's worth sharing before you go further, because the result surprised me.
The exception/observability path works
Forcing indexedDB.open() to throw (simulating disabled storage / quota / private browsing) now correctly surfaces:
[DesignLedger] SAVE FAILED - progress was NOT persisted: InvalidStateError: Storage is disabled
and last_save_error gets set. That's a real, verified fix for the "exception vanishes into an unobserved task" failure mode. Good.
The success path still silently loses data
This is the part I'd hold on. I ran the actual (unmodified) state.py from this branch against real Pyodide v0.28.3 in headless Chromium — not a mock, the real save_async() / run_js() / IndexedDB code — and every real-method call I tried reports success while the data is not actually in IndexedDB:
ledger.save(...), waited 3s, read IndexedDB directly → lostledger.save(...)+await ledger.flush()→save_pending=False,last_save_error=None(reports success) → still lostawait ledger.asave(...)(the fully-awaited API, no background task at all) → lost- Isolated, minimal case:
await ledger.save_async()called directly as a single bound-method call, nothing else → lost, 0/5 across five separate trials
The confusing part, and why I dug further before posting: if I take the exact same IndexedDB JS and run it as a plain top-level script (not through the DesignLedger class, just inlined directly in the same test), it persists reliably (5/5). So it's not that the JS/IndexedDB logic itself is wrong — something about going through the class's bound async def save_async(self) method specifically is where it breaks, even with zero background-task indirection. I was not able to pin down the exact Pyodide-internal mechanism (my best guess is some interaction between transaction auto-commit timing and the extra WASM↔JS boundary crossings introduced by nested coroutine calls, but that's a guess, not a finding).
I think this means the regression tests in test_state.py aren't catching the actual bug, since all four of them monkeypatch save_async itself (fake_save_async / failing_save_async), so none of them exercise the real run_js/IndexedDB code path at all — they verify the new bookkeeping logic (callbacks, last_save_error, flush(), asave() exception propagation) works correctly given whatever save_async() reports, but not whether save_async() itself reliably tells the truth in a real browser.
What I'd suggest before merging
- The
labs/tests/browser_smoke.pyreal-Pyodide test your PR description mentions as pending — I think that's exactly the right instinct, and it's what would have caught this. Worth prioritizing before merge rather than as a follow-up. - If you want, I can share my repro scripts (Playwright + real Pyodide, no marimo/Quarto build needed, just the unmodified
state.py) so you can reproduce this directly and dig into the transaction-commit timing — happy to hand those over. - Given the issue title is specifically about students silently losing data, I'd want the success path verified end-to-end in real Pyodide before this closes #1985 — right now it closes the loud failure mode but the silent one (the one the issue is actually about) still reproduced for me every time I tried it against real IndexedDB.
Not trying to be discouraging — the API design here is the right shape, and the exception-path fix is real progress. It's specifically the "does store.put() reporting success actually mean the data is durably written, when called from inside this class" question that needs to be nailed down before this can close the issue.
|
Update for @Shashank-Tripathi-07 - found the actual root cause, and it's not what either of us guessed. Root cause: Python name mangling, not a Pyodide FFI quirk.
The smoking gun: I tried an alternate implementation using Fix: renamed New regression test: added Happy to also wire this into Thanks for catching this-genuinely would have shipped a silent-data-loss bug disguised as a fix for a silent-data-loss bug without this review. |
|
@aadityansha06 following up on the CI wiring for 1. Pin
- name: 🎭 Install Playwright + Chromium
env:
# Fixed, HOME-independent location. labs/tests/conftest.py
# redirects $HOME for test isolation (so lab tests don't touch a
# real ~/.mlsys), which would otherwise make Playwright look for
# its browser cache in the wrong place once pytest runs. See the
# WASM persistence regression test step below.
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.pw-browsers
run: |
pip install playwright
python3 -m playwright install --with-deps chromium2. Same pin on "Browser smoke test (real Chromium + Pyodide)" Same reason, this step also launches a real browser and needs to resolve the same install location. - name: 🌐 Browser smoke test (real Chromium + Pyodide)
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.pw-browsers
run: |
python3 labs/tests/browser_smoke.py --labs-dir /tmp/wasm-smoke3. Add the new persistence test as its own step, right after the one above This is the actual wiring, it runs your new real-Pyodide + real-IndexedDB regression test as part of this job, so the fix for the silent-save bug is covered in CI going forward instead of only locally. Worth having this in CI specifically because the mocked # =====================================================================
# WASM/IndexedDB persistence regression test (#1985 / PR #1988)
# =====================================================================
# DesignLedger.save_async() previously reported success while
# silently failing to persist to IndexedDB, due to a Python
# name-mangling bug (globalThis.__mlsys_temp_state written inside the
# class body was rewritten to globalThis._DesignLedger__mlsys_temp_state,
# desyncing it from the plain name the embedded JS read). Mocked unit
# tests (mlsysim/tests/test_state.py) can't catch this class of bug
# since they replace save_async() entirely. This runs the real
# save_async() against real Pyodide + real IndexedDB in headless
# Chromium and reads the write back through a separate connection.
- name: 🧪 WASM persistence regression test (real IndexedDB)
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.pw-browsers
run: |
python3 -m pytest labs/tests/test_wasm_persistence.py -vOnce this is pushed, you can tag me and I'll do another review for a final
|
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
save_async() wrote to globalThis.__mlsys_temp_state from inside the DesignLedger class body. Python's name mangling silently rewrote that assignment to globalThis._DesignLedger__mlsys_temp_state, desyncing it from the plain __mlsys_temp_state the embedded JS string read -- so save_async() reported success on every call while actually persisting undefined. Deterministic, not a race: reproduced 0/5 via real Pyodide + IndexedDB in headless Chromium, isolated during @Shashank-Tripathi-07's review on PR #1988. Fix: renamed to _mlsys_temp_state (single leading underscore, which Python does not mangle). Added labs/tests/test_wasm_persistence.py: a permanent regression test that runs the real save_async() against real Pyodide + IndexedDB in headless Chromium and reads the write back through a separate connection. Verified this test fails correctly (0/5 persisted) against the pre-fix code and passes (5/5) against the fix -- mocked tests (mlsysim/tests/test_state.py) cannot catch this class of bug since they replace save_async() entirely.
Wires labs/tests/test_wasm_persistence.py into CI per @Shashank-Tripathi-07's request, so #1985's silent IndexedDB data-loss bug (and the Python name-mangling bug that caused it) can't regress unnoticed. PLAYWRIGHT_BROWSERS_PATH is pinned to a fixed, HOME-independent location on both the install and test steps, since labs/tests/conftest.py redirects $HOME for test isolation, which would otherwise make Playwright look for its browser cache in the wrong place once pytest runs. Kept --override-ini="addopts=" on the pytest invocation: the root pyproject.toml's addopts requires --cov=book/tools, but pytest-cov isn't installed in this job, which is the exact failure I hit locally before adding this override.
86dc71e to
13a4327
Compare
…mports marimo's html-wasm export now shells out to uv to resolve local imports, and this job never installed it, so every export step failed outright before reaching the browser-level checks. Caught by rebasing this branch onto current dev and running wasm-smoke-test for real: dev's own last green run of this workflow predates whatever marimo release added this requirement, so this was already latent on dev, not something this branch introduced.
|
@Shashank-Tripathi-07 The CI wiring is pushed and green on my fork against the latest dev. The fixes are verified, and this is ready to merge. Recap of the full change:
Let me know if you need anything else to approve this, otherwise it's good to go. |
Root .gitignore already covers .venv, venv/, and their recursive forms (**/.venv/, **/venv/) everywhere in the repo. .venv312 was a one-off name from local Python 3.12 testing, doesn't generalize to other contributors' env-naming choices, and isn't related to this PR's actual fix.
|
Hi @aadityansha06 , I've done the complete review, a few final surgical changes as required. This PR now looks complete and ready from my end and it's a Let's get this merged. |
|
Thanks @aadityansha06! 🎉 I added @aadityansha06 to labs, mlsysim for: bug, code, test. The contributor tables are now handled directly by this workflow; no follow-up command is needed. |


Summary
Fixes a silent data-loss bug where
DesignLedger.save()in WASM/Pyodide used a fire-and-forgetasyncio.create_task(...)for persistence, so IndexedDB failures were swallowed and students could lose progress across all 34 browser lab notebooks with no indication anything went wrong.Area
mlsysim/mlsysim/labs/state.py, not in the list above but the actual area this PR touches)Changes
save_async()now raises on IndexedDB failure instead of swallowing it into aprint()save()attaches a done-callback to the background WASM save task and records failures onlast_save_error/ exposes in-flight status viasave_pending, instead of the exception vanishing into an unobserved taskasave()— an async variant ofsave()for callers that can await, guaranteeing persistence or raisingflush()— awaits any in-flight background save, re-raising its exception if it failedmlsysim/tests/test_state.pycovering the native save/load roundtrip, a successful WASM save clearinglast_save_error, a failing WASM save being captured (not silently dropped), andasave()propagating exceptions directlyTesting
quarto render)pytest tests/)tito module test NNfor affected module(s)Verified locally and via CI:
mlsysim/tests/full suite passes (pytest tests/ -v --tb=short), including 4 new tests intest_state.pylabs/tests/test_static.pyandtest_protocol.pypass unaffected, sincesave()'s public signature (track,step,design,chapter) is unchangedmlsysim-validate-dev.ymlworkflow viaworkflow_dispatchagainst this branch —Run TestsandBuild Docs Siteboth passed. (Check Linksshowed a failure, but that's an unrelated pre-existing config issue in the link-check job —fail_on_broken: falseis already set, but the underlyinglychee-actionstill reports job failure when its glob resolves to zero files. Not something this diff touches.)Added labs/tests/test_wasm_persistence.py — a permanent real-Pyodide + real-IndexedDB regression test (see comment below for the full story). Verified red→green: fails correctly against the pre-fix code, passes against the fix.
Environment (local testing):
ubuntu-latestrunner, viaworkflow_dispatchon this branchRelated Issues
Fixes #1985
By submitting this PR, you agree to release your contribution under the project's license.