field notes · sugarlabs/musicblocks · issue #8069

The Anatomy of a Race Condition

 
reproduced in Chrome DevTools on throttled 3G, gone after the fix
Two chains of blocks, one blue and one green, converging on a single shared slot from opposite sides

While contributing to Music Blocks — Sugar Labs’ music-education app — I traced a console error to a positioning patch that silently failed on slow loads. Inside that one small fix live seven JavaScript mechanisms every working engineer leans on daily. This lab teaches each one with code you can poke. Budget 20–30 minutes.

1. The cast of characters

~2 min · read

Music Blocks has a search box for finding programming blocks. When you type, a dropdown of suggestions appears. Three facts set up everything that follows:

• The dropdown is not inside the search box — it’s a separate element attached to the end of the page. Something must calculate where to draw it.
• The search widget is created late in startup, after palettes and instrument sounds load — anywhere from 3 to 60+ seconds in.
• The positioning code lived in a different file that could not know when the widget would exist — so it guessed, with a timer.

Keep one question in mind through every section: “how does this code know the thing it needs is ready?” That question is the whole bug.

2. Measuring the screen: getBoundingClientRect()

~3 min · drag the box

Every element can tell you where it currently sits in the viewport (the visible window), via element.getBoundingClientRect(). The fix calls it on the search box every time the dropdown renders, then pins the dropdown to those live numbers.

Lab · live rectangle drag the blue box
search box
left–
top–
bottom–
width–
// the fix, in miniature: dropdown.style.left = rect.left + "px"; dropdown.style.top = rect.bottom + 2 + "px"; // 2px gap below the box dropdown.style.width = rect.width + "px";

Notice the numbers change as you drag. That’s the key property: the rect is a fresh measurement, not a stored value. Code that re-measures can never be stale; code that measures once can.

3. Two coordinate systems: absolute vs fixed

~5 min · scroll inside the frame

CSS gives you two different answers to “where should this element go?” position: absolute pins it to a spot in the page — scroll the page, and it rides along. position: fixed pins it to a spot on the glass — the page moves underneath it.

Music Blocks’ search palette floats on the glass (like fixed), but jQuery UI’s default dropdown is positioned in the page (like absolute). While nothing moves they agree. The moment the page scrolls or the layout shifts, they disagree — and the dropdown visibly detaches. Try it:

Lab · the detaching dropdown
palette (floats on glass)
pitch
Dropdown is 0px away from the search box. attached

What you just saw: in default mode the dropdown scrolled away with the page content, because its position was computed once in page coordinates. In patched mode it re-measures the box on the glass and uses fixed, so scrolling cannot separate them. This is the entire visual payoff of the fix — and why a previous contributor wrote the patch in the first place.

4. jQuery in ten minutes of honesty

~4 min · run each snippet

jQuery predates most modern browser APIs. Its core trick: jQuery("#search") (or $("#search")) finds elements and wraps them in an object bristling with methods. jQuery UI adds ready-made widgets — autocomplete among them — that store their live instance on the element via .data(). The demos below run against a 20-line reimplementation so you can see there’s no magic:

Lab · mini-jQuery playground
#demo-box
$("#demo-box").length
$("#demo-box").css("background", ...)
$("#demo-box").data("ui-autocomplete")
$("#demo-box").autocomplete({ source: [...] })  // create widget
$("#nope").length

Run snippet 3 before and after snippet 4. Before: undefined — no widget exists yet. After: an object. That exact check — “does .data("ui-autocomplete") return anything?” — is what the old polling code asked twenty times, and what the fixed function asks once, at the right moment. Snippet 5 shows jQuery’s quiet failure mode: selecting nothing isn’t an error, just an empty wrapper — which is why guard clauses check .length.

5. Monkey-patching & idempotency

~4 min · wrap the method

The positioning fix works by replacing a method on the live widget: save the original _renderMenu, substitute a wrapper that calls the original and then adds positioning. That’s monkey-patching — powerful, and dangerous in one specific way: wrap twice and your addition runs twice. Every layer of wrapping is another onion skin that never comes off.

Lab · the wrapping onion
_renderMenu
Wrap depth: 0 · guard flag: unset
// click "patch", then renderMenu(). Then patch again and re-render.

With no guard, each patch adds a ring, and one render positions the dropdown once per ring — harmless-looking today, a performance leak and a debugging nightmare later. The guarded version stamps a flag on the instance (_mbPositionFixApplied) and refuses to re-wrap: call it a hundred times, one ring. A function that’s safe to call repeatedly is called idempotent — and a reviewer on the competing PR asked for precisely this guard.

6. Why setTimeout(..., 0) isn’t zero

~3 min · run it

Inside the patch there’s a strange line: the positioning happens in setTimeout(…, 0). Zero milliseconds — so, immediately? No. JavaScript runs one thing at a time; a timeout callback, even at 0ms, is placed in a queue and runs only after the current work finishes. That’s the point: let jQuery UI completely finish laying out the menu, then apply our coordinates so nothing overwrites them.

Lab · run to the end, then the queue
log("A: draw the suggestion list");
setTimeout(() => log("C: pin dropdown to the box"), 0);
log("B: jQuery UI finishes its own layout");

runs now (call stack)

queued for after

// output appears here

The order is always A, B, C — C last, even at “0ms.” Engineers say the callback runs “on the next tick.” One mechanism, two very different uses in this story: here it’s used correctly (defer until current work completes); in the next section it was used as a guess about the future — and that’s where it breaks.

7. The race, playable

~5 min · the centerpiece

The old code needed the widget to exist before patching it. Its strategy: start 1 second after page load, check every half-second, give up after 20 tries (~11s total) and log an error. The app, meanwhile, creates the widget whenever startup finishes — 3 seconds on a warm cache, 40+ on classroom Wi-Fi. Two independent timelines, no coordination: a race condition. Drag the slider; press play.

Lab · poller vs. startup
poller (checks every 0.5s, 20 tries)
app startup → widget created

At 3s the poller wins and everything works — which is exactly why this bug shipped: it passed on every developer’s fast machine. At 12s and beyond the poller dies before the widget is born, and no code path ever revisits the question. Now switch to fix: event-driven and replay any speed. There is no poller lane at all: the patch is called by the same code that creates the widget, so it fires at the exact moment of creation — the race isn’t won, it’s deleted.

The engineering lesson of the whole lab: when code needs to run after an event, attach it to the event, not to a clock. Timers encode a guess about how fast the world is; guesses about speed are the bugs that only appear on someone else’s machine.

8. The actual fix, annotated

~3 min · read

Everything above compresses into this diff:

// BEFORE — jquery-setup.js guessed with a timer:
setTimeout(fixAutocompletePosition, 1000);   // start guessing
// ...checks 20x, every 500ms, then:
console.error("Autocomplete setup failed…");  // gives up forever

// AFTER — jquery-setup.js just defines a capability:
window.fixSearchAutocompletePosition = function () {
    if (!$search.length || !$search.data("ui-autocomplete")) return false;  // §4 guards
    if (!instance || instance._mbPositionFixApplied) return false;          // §5 idempotency
    instance._renderMenu = function (ul, items) {          // §5 monkey-patch
        originalRenderMenu.call(this, ul, items);
        setTimeout(() => {                                  // §6 next tick
            const rect = searchInput.getBoundingClientRect(); // §2 fresh measure
            dropdown.style.position = "fixed";               // §3 on-glass coords
            ...
        }, 0);
    };
    instance._mbPositionFixApplied = true;
    return true;
};

// AFTER — search-controller.js calls it at the birthplace of the widget:
$search.autocomplete({ ... });                       // widget is created HERE…
if (typeof window.fixSearchAutocompletePosition === "function") {
    window.fixSearchAutocompletePosition();          // …so patch it HERE — §7, race deleted
}

Every line now traces back to a mechanism you’ve poked with your own hands.

9. Check yourself

~4 min · answer before revealing
1 · The dropdown never appeared at all for slow-loading users. True or false?
False — and this distinction matters in bug reports. The dropdown always appeared with all its items; jQuery UI’s default positioning drew it in approximately the right place. What failed silently was the re-anchoring patch, so the dropdown could drift once the page scrolled or the layout shifted. No feature was lost; a safeguard was.
2 · Why not just increase the retries from 20 to 200?
Because that changes the guess, not the design. Some machine somewhere is always slower than your budget (and 200 retries costs 100 seconds of background timers on every fast load, for nothing). The categorical fix is to remove the guess: run the patch from the code that creates the widget, so timing can’t matter at all.
3 · What breaks if you call the unguarded patch function twice?
Each call wraps the current _renderMenu — including the previous wrapper. Two calls means every render runs the positioning logic twice; N calls, N times. The guard (_mbPositionFixApplied stamped on the instance) makes the second call return false without touching anything — idempotency.
4 · Why does the patch position the dropdown inside setTimeout(…, 0)?
Because when the wrapper runs, jQuery UI hasn’t finished its own menu layout yet — set coordinates synchronously and the library’s positioning would run after us and overwrite them. Queuing at 0ms means “after the current work completes,” so our coordinates land last and stick.
5 · A competing PR inlines the same 15 lines in two files instead of sharing one function. Name one cost and one benefit.
Cost: duplication — the copies can drift apart under future edits, and one copy patches a code path that appears to have no callers (dead code). Benefit: zero coupling — each init site is self-contained with no window global. Neither answer is stupid; engineering is choosing which cost you’d rather carry. (The fix I submitted — PR #8099 — chose the single guarded function.)
Written from a real contribution: issue #8069, which I filed and then fixed in PR #8099 (a competing take lives in PR #8085 — quiz question 5 weighs the two). Files touched: js/utils/jquery-setup.js, js/activity/search-controller.js.