Conversation
The synchronous drains (poll_tasks, drain_remaining_effects and render_immediate_with_writer) polled tasks until the queue was empty. A task that wakes its own waker before returning Pending is queued again right after its poll, so the same task came back forever and wait_for_work never returned Pending to its executor. With tokio this happens to any task looping over a tokio resource once its cooperative budget runs out off the runtime's worker threads, because the budget is only refilled when the executor gets control back. A pass now polls each task at most 32 times (TaskPass) and queues any task set aside at that bound for the next pass. wait_for_work yields to the executor with yield_now between passes when tasks remain queued, instead of waiting on a channel that has already delivered their wakeups. Short chains of immediate wakeups still settle within one render_immediate call.
This branch has not been deployed
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.
Problem
VirtualDom::wait_for_worknever returnsPendingwhile a spawned task keeps waking itself, so the executor driving it never runs again. The thread spins at 100% CPU without making a syscall and the UI stops updating for good.The synchronous drains poll tasks until the queue is empty:
poll_tasks:while !self.has_dirty_scopes() { let Some(task) = self.pop_task() ... }drain_remaining_effects: the same loop again for tasksrender_immediate_with_writer:while let Some(work) = self.pop_work()A task that wakes its own waker before returning
Pendingis queued again byqueue_eventsright after its poll, sopop_taskhands the same task back forever. Even with a bounded pass,wait_for_workwould go back intowait_for_eventnext, whose channel has already delivered that task's wakeup, so it would never be woken.This is easy to hit with tokio. Once a task has used its cooperative budget, the next tokio resource it polls (an
interval, a channel,consume_budget) returnsPendingand wakes the task. On a thread that is not one of the runtime's workers (for exampleRuntime::block_onon a multi-thread runtime) that wake is immediate. The budget is only refilled once the executor gets control back, which never happens, so a plainloop { interval.tick().await; ... }task freezes the app. I hit this in a production terminal app built on dioxus: its UI froze while a spinner task sat in exactly this loop.Fix
TaskPassinscheduler.rs). A task that comes up again after that is set aside and queued again when the pass ends.poll_tasks,drain_remaining_effectsandrender_immediate_with_writerall use it.wait_for_workyields to the executor with the existingyield_nowinstead of waiting on the channel, then runs the next pass.Short chains of immediate wakeups still settle within one call, so
render_immediatestill converges the waynested_suspense_resolves_clientexpects (a first version that polled each task only once per pass broke that test). The bound of 32 matches the batch size the suspense loops already use before yielding. Setting a task aside instead of ending the pass means a busy task in a parent scope cannot starve tasks in child scopes.The suspense loops (
wait_for_suspense_work,render_suspense_immediate) already yield every 32 items and are unchanged.Tests
Five tests in
packages/core/tests/task.rs. Each drives the VirtualDom on its own thread with a deadline, because the bug is a hang:wait_for_work_yields_between_polls_of_a_self_waking_task: the task keeps being polled whilewait_for_workis pendingsynchronous_drains_return_with_a_self_waking_task:process_eventsandrender_immediatereturn, and each call polls the task againeffect_draining_returns_with_a_self_waking_task: an effect that starts such a taskself_waking_task_does_not_starve_a_child_taskexhausted_tokio_coop_budget_does_not_freeze_the_virtual_dom: the tokio case above, viatokio::task::coop::consume_budgetunderRuntime::block_onThe first four fail on current
mainwith "the VirtualDom did not hand control back within 10s". The effect test was added afterwards to cover the effect drain on its own; it hangs the same way when only that drain uses the unboundedpop_task, as it does onmain. Each part of the fix is covered: removing thewait_for_workyield, lifting the bound, reverting any one of the three drains to the unbounded pop, or dropping the set-aside tasks instead of queueing them again each makes at least one of these tests fail.cargo test -p dioxus-corepasses (211 passed, 0 failed, 7 ignored).cargo fmt --check,typosandcargo clippy -p dioxus-core --no-deps --tests --all-features --all-targets -- -D warningsare clean. (Without--no-deps, clippy 1.97 stops earlier on two existingredundant reference in format! argumentlints indioxus-core-macro, which this PR does not touch.)