Stop UI-thread NIFs blocking the only scheduler (MOB-164) - #139
Merged
Conversation
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>
Uh oh!
There was an error while loading. Please reload this page.
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 freeto 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.
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_BOUNDscreen_info,scroll_to,clipboard_get,webview_can_go_backsafe_area(iOS returns insets viascreen_info)scroll_info,color_scheme,set_theme,device_battery_state,device_foreground,device_orientation,battery_level,audio_stop_playback,audio_set_volumeEach 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,screenInfoandclipboardGetwaited onlatch.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 ornilwhen 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.mdnow records two things it got wrong: the scope (above), and the claim that "every harness input NIF blocks" — false for Android'stap/1, which needs a bridge method no generated app defines and so returns:not_loadedbefore 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_getor iOS'sbattery_levelto.flags = 0fails 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.
🤖 Generated with Claude Code