fix: image occlusion masks lost when adding notes#5155
Open
LTimothy wants to merge 2 commits into
Open
Conversation
Contributor
Author
|
I suspect this explains why some users on this thread hit this more reliably than others: a race condition between the mask editor's reset, which fires the moment Add is clicked, and the background save of the note. The script below reproduces it more often by injecting a small delay into that save; on my setup, before this fix it went from 0 or 1 empty out of 20 with no delay to 20/20 with a 20ms delay, and after this fix it stayed 0/20 either way. Suggests the trigger is whatever slows a machine down, not the user. The proposed PR removes the reset's ability to run before the note is saved, eliminating the race condition. """
Demo for #4754.
1. Save as <profile-folder>/addons21/io_4754_demo/__init__.py in a throwaway profile.
2. Launch Anki. It runs on its own once the profile loads, no clicking
needed, a dialog reports the result when it's done.
3. Change DELAY_MS below and relaunch to compare.
"""
from pathlib import Path
import aqt
import aqt.editor
from anki.models import NotetypeId
from aqt import gui_hooks, mw
from aqt.qt import QMessageBox, QTimer
DELAY_MS = 0
N = 20
IMAGE = str(Path(aqt.editor.__file__).parent / "data/web/imgs/anki-logo-thin.png")
state = {"added": 0, "note_ids": []}
def slow_down_saves() -> None:
if DELAY_MS <= 0:
return
orig = mw.taskman.run_in_background
def delayed(task, on_done=None, args=None, uses_collection=True):
QTimer.singleShot(
DELAY_MS,
lambda: orig(task, on_done, args=args, uses_collection=uses_collection),
)
mw.taskman.run_in_background = delayed
def start() -> None:
QTimer.singleShot(2000, step_open)
def step_open() -> None:
mw.col.add_image_occlusion_notetype()
ntid = mw.col.models.id_for_name("Image Occlusion")
state["addcards"] = aqt.dialogs.open("AddCards", mw)
state["addcards"].notetype_chooser.selected_notetype_id = NotetypeId(ntid)
state["ntid"] = ntid
slow_down_saves()
gui_hooks.add_cards_did_add_note.append(on_added)
QTimer.singleShot(500, load_image)
def load_image() -> None:
addcards = state["addcards"]
addcards.editor.setup_mask_editor_for_new_note(IMAGE, state["ntid"])
poll_canvas_ready(0)
def poll_canvas_ready(attempt: int) -> None:
addcards = state["addcards"]
def on_result(res):
if res:
draw_and_add()
elif attempt < 30:
QTimer.singleShot(200, lambda: poll_canvas_ready(attempt + 1))
addcards.editor.web.evalWithCallback(
"!!globalThis.canvas && globalThis.canvas.getObjects().length >= 1",
on_result,
)
DRAW_AND_SAVE_JS = """
(function() {
const canvas = globalThis.canvas;
const rect = new fabric.Rect({
left: 20, top: 20, width: 40, height: 30,
fill: "rgba(0,0,0,0.4)",
});
rect.id = "rect-" + Date.now();
canvas.add(rect);
const tmp = new fabric.Rect({left: -100, top: -100, width: 1, height: 1});
canvas.add(tmp);
canvas.remove(tmp);
})();
"""
def draw_and_add() -> None:
addcards = state["addcards"]
addcards.editor.web.eval(DRAW_AND_SAVE_JS)
QTimer.singleShot(0, addcards.add_current_note)
def on_added(note) -> None:
state["added"] += 1
state["note_ids"].append(note.id)
if state["added"] >= N:
QTimer.singleShot(1000, check)
else:
load_image()
def check() -> None:
empty = 0
for nid in state["note_ids"]:
if mw.col.get_note(nid).fields[0].strip() == "":
empty += 1
summary = f"DELAY_MS={DELAY_MS}: {empty}/{N} notes came out with an empty Occlusions field."
print(f"[io-4754-demo] {summary}")
QMessageBox.information(mw, "io-4754-demo result", summary)
gui_hooks.profile_did_open.append(start) |
reset_image_occlusion() was queued immediately after the save that precedes _add_current_note(), so the reset's empty occlusions write raced the background add of the same Note object. Depending on event timing the note could be added with an empty Occlusions field and no warning, or the add could be spuriously rejected with 'no occlusion created' while masks were visibly present (ankitects#4754). Resetting only after a successful add matches the ordering the non-legacy TS path already uses, and means a rejected add no longer wipes the drawn masks.
Field content was committed through a single shared ChangeTimer, so scheduling one field's save silently cancelled any other field's pending save. For image occlusion notes this dropped the debounced Occlusions write when the user typed into another field within 600ms of the last mask edit, and later saveOcclusions() calls never re-scheduled it because the store already held the same value: masks drawn since the last commit were missing from the added note (ankitects#4754). Use one timer per field, and make ChangeTimer.fireImmediately() take the action before running it, as an action scheduled while a flush was in flight used to be discarded when the flush finished.
LTimothy
force-pushed
the
fix/io-occlusion-add-races
branch
from
July 15, 2026 03:12
b6d4f28 to
81912b6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issue (required)
Closes #4754
Summary / motivation (required)
Image occlusion masks could silently go missing from a note at add time. Two separate problems caused this, both timing dependent, which is why the bug was rare and hard to reproduce.
Problem 1: the editor reset races the add (desktop). Clicking Add on an image occlusion note saved the note, queued the actual add, and reset the mask editor all at once. The reset clears the canvas, and clearing the canvas writes an empty value into the Occlusions field of the same note object the add was about to read. Depending on which message arrived first: the add worked normally (the common case), the note was added with no masks and no warning, or the add was wrongly rejected with "no occlusion created" while the masks were plainly visible. Fix: reset the mask editor only after the add succeeds, matching the ordering the non-legacy TS editor page already uses. This also means a rejected add no longer wipes the masks you drew.
Problem 2: one field's save cancels another's. All fields committed through a single shared save timer, and scheduling a save cancelled the timer's previous pending action. Mask edits commit the Occlusions field through that timer with a 600 ms debounce, so typing into Header or Back Extra within 600 ms of the last mask edit threw away the pending occlusions write. It was never retried, because later saves saw the store already held the same value and skipped notifying. Every mask drawn since the last committed write was then missing from the added note. Fix: give each field its own timer. This also surfaced a second bug in
ChangeTimeritself: an action scheduled whilefireImmediately()was awaiting a previous action was silently discarded when that await finished; fixed by taking the action before running it and letting a concurrent flush wait for the run in flight.Steps to reproduce (required, use N/A if not applicable)
Problem 2 (reliably reproducible):
Problem 1 (event-loop timing dependent, not reliably reproducible by hand):
How to test (required)
Checklist (minimum)
./ninja checkor an equivalent relevant check locally.Details
ts/tests/e2e/io-mask-save-race.spec.tsreproduces problem 2 with real mouse drawing and real keystrokes at the real 600 ms timing. Fails on currentmain(payload contains 1 cloze instead of 2, consistently across repeated runs) and passes with this change.ts/lib/editable/change-timer.test.tscovers the discarded-action case inChangeTimerand fails without the fix.just test-e2e) passes repeatedly across fresh profiles.just fmt,just lint, and the vitest suite are clean.AddCardsflow and delivers the racing write inside the vulnerable window, and confirmed by inspection that after this change the empty write can no longer be issued while an add is in flight.Before / after behavior (optional)
Before: masks could be silently dropped from a newly added note (problem 2, common), or the note could be added completely empty of masks / spuriously rejected (problem 1, rare). After: neither race is possible; a rejected add also no longer clears the masks you've drawn, so you can fix the note and retry instead of redrawing.
Risk / compatibility / migration (optional)
Low risk. Both fixes are ordering/scoping changes with no behavior change outside the affected race windows. No schema, API, or migration changes.
UI evidence (required for visual changes; otherwise N/A)
N/A (no visual/UI changes).
Scope
This PR contains two fixes rather than one. I considered splitting them, but both are root causes of the same reported bug (#4754) discovered during the same investigation, they are already isolated into two separate commits for review, and neither is a complete fix for the issue on its own. Happy to split into two PRs if you'd prefer.