Skip to content

Stop UI-thread NIFs blocking the only scheduler (MOB-164) - #139

Merged
GenericJam merged 2 commits into
masterfrom
fix/mob-164-blocking-nifs
Sep 6, 2026
Merged

Stop UI-thread NIFs blocking the only scheduler (MOB-164)#139
GenericJam merged 2 commits into
masterfrom
fix/mob-164-blocking-nifs

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Android runs the BEAM with -S 1:1one normal scheduler — so a NIF that waits on the UI thread stops every process on the device for as long as it waits.

MOB-160 established the rule "if it waits on the UI thread, it is dirty" and then applied it only to the input NIFs in front of us. The rule was false of this codebase the moment it was written.

Flagged ERL_NIF_DIRTY_JOB_IO_BOUND

platformNIFs
bothscreen_info, scroll_to, clipboard_get, webview_can_go_back
Android onlysafe_area (iOS returns insets via screen_info)
iOS onlyscroll_info, color_scheme, set_theme, device_battery_state, device_foreground, device_orientation, battery_level, audio_stop_playback, audio_set_volume

Each Android one waits on a Kotlin latch; each iOS one dispatch_syncs, verified against its actual function body rather than by name. The flags diverge by platform because the code does — Android answers several of these without touching the UI thread, so flagging them there would buy a scheduler hop and nothing.

Three of them could hang the VM outright

getSafeArea, screenInfo and clipboardGet waited on latch.await() with no timeout. On a single normal scheduler that is not a stall but a hang: if the main thread never answers, no Erlang process on the device runs again. Bounding them is the matching GenericJam/mob_new#61; each already returns a zero-filled array or nil when there is no activity, so a timeout needs no contract change.

The decision record carries a correction, not an edit

decisions/2026-09-05-input-nifs-are-dirty-io.md now records two things it got wrong: the scope (above), and the claim that "every harness input NIF blocks" — false for Android's tap/1, which needs a bridge method no generated app defines and so returns :not_loaded before any JNI call. Its dirty hop buys nothing.

The wrong version is the part a future reader needs to recognise, which is why it is corrected in place rather than deleted.

Tests

The scheduling test now enumerates every affected NIF on both platforms. Mutation-checked: reverting Android's clipboard_get or iOS's battery_level to .flags = 0 fails it.

The lesson is narrower than the rule: a principle stated in a decision record is worth exactly as much as the test that checks it.

Suite 1550 passing, credo clean, clang-format clean.

⚠️ Rebuild native to pick this up — the registration table compiles into each app.

🤖 Generated with Claude Code

Android runs the BEAM with `-S 1:1` — one normal scheduler — so a NIF that
waits on the UI thread stops every process on the device for as long as it
waits. MOB-160 established the rule "if it waits on the UI thread, it is
dirty" and then applied it only to the input NIFs in front of us. The rule was
false of this codebase the moment it was written.
Flagged ERL_NIF_DIRTY_JOB_IO_BOUND:
Android: screen_info, scroll_to, safe_area, clipboard_get,
webview_can_go_back — each waits on a Kotlin latch
iOS: those plus scroll_info, color_scheme, set_theme,
device_battery_state, device_foreground, device_orientation,
battery_level, audio_stop_playback, audio_set_volume — thirteen
dispatch_syncs, each verified against its function body
The flags diverge by platform because the code does: Android answers several
of these without touching the UI thread, and there is no iOS `safe_area` NIF
because iOS returns insets through screen_info.
Three of the Android waits — getSafeArea, screenInfo, clipboardGet — had no
timeout at all. On a single normal scheduler that is not a stall but a hang:
if the main thread never answers, no Erlang process on the device runs again.
Bounding them is the matching mob_new change; each already returns a
zero/nil "unknown" value when there is no activity, so a timeout needs no
change to the contract.
The decision record carries a correction rather than an edit, because the
wrong version is the part worth recognising. It also claimed "every harness
input NIF blocks", which is false for Android's tap/1: it needs a bridge
method no generated app defines, so it returns :not_loaded before any JNI
call and its dirty hop buys nothing.
The scheduling test now enumerates every affected NIF on both platforms.
Mutation-checked: reverting Android's clipboard_get or iOS's battery_level
fails it. A principle stated in a decision record is worth exactly as much as
the test that checks it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-merge review returned DO NOT MERGE, and was right on all three.
**Two NIFs were flagged that do not block.** `audio_stop_playback` and
`audio_set_volume` both contain `dispatch_sync` — nested inside a
`dispatch_async`, so it runs on the main thread and the scheduler never waits.
I classified them by grepping for the token, which is the exact mistake this
change was written to correct, made two lines below the correction describing
it. Reverted to a normal scheduler.
**iOS `safe_area` exists and my comment said it did not.** It is registered,
already IO_BOUND, blocks on `dispatch_semaphore_wait`, sits on the screen-mount
path, and its own header documents a past deadlock that stopped the app
launching on iPad. It was in the Android-only list, so nothing guarded the iOS
one. Moved, comment deleted.
**"The test enumerates them so the rule is enforced" was not true.** A test
iterating a hand-written list of NIFs we remembered cannot fail for one nobody
thought of — the same gap as the bug, one layer up.
So the enforcement is now a completeness check: the union of the classified
lists must equal the registration table exactly, and adding a NIF fails the
build until someone decides how it schedules. It checks both directions,
because a flag is wrong when spurious as well as when missing. Mutation-checked
on all three: an unclassified NIF fails, flagging a prompt one fails, and
unflagging a blocking one fails.
Also recorded, per the review: there is one dirty IO scheduler and
`resolve_ipv4` already lives on it, so a slow DNS lookup can now delay a screen
mount in a way it could not before. Still a large net win — blocking one dirty
scheduler beats blocking the only normal one — but it is a real new edge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam merged commit 2577307 into masterSep 6, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@GenericJam