Skip to content

vmcore: fix per-VP task pool thread/fd leak on guest reset - #3901

Merged
Steven Malis (smalis-msft) merged 3 commits into
microsoft:mainfrom
bitranox:fix-vp-pool-thread-leak
Oct 9, 2026
Merged

Steven Malis (smalis-msft) merged 3 commits into
microsoft:mainfrom
bitranox:fix-vp-pool-thread-leak

Conversation

@bitranox

Copy link
Copy Markdown
Contributor

Problem

A device that builds a target-VP task driver via ThreadDriverBackend (netvsp, storvsp, and any resource using VmTaskDriverSource::current() on a per-VP thread) spawns a dedicated IoPool thread. Since #3310 that thread lends a clone of its own pool's driver into the CURRENT_DRIVER thread-local for the entire lifetime of pool.run().

IoPool::run returns only once the last strong IoDriver is dropped. The lent clone is a strong self-reference held across the whole run, so after the device closes its channel and drops its own ThreadDriver, the pool's task queue never closes: the thread parks forever in recv().await, never releasing its epoll fd and wakeup eventfd.

A guest reset (reboot) repeatedly rebuilds these per-channel drivers, so every reboot stacks a fresh set of parked threads and fds. On a Linux guest with VMBus netvsp/storvsp devices, the worker leaked ~132 threads and ~247 fds per reboot until it hit its fd limit.

Regressed in #3310 (thread-local IO dispatch).

Fix

Publish a weak reference into CURRENT_DRIVER. It is only borrowed by code running on the pool's own thread while a task executes, so a strong driver provably still exists and the transient upgrade succeeds; at rest the thread holds no strong reference, so the pool shuts down and the thread exits once the device drops its driver. Adds WeakIoDriver / IoDriver::downgrade to pal_async and a regression test.

@bitranox
Robert Nowotny (bitranox) requested a review from a team as a code owner July 9, 2026 14:49
Copilot AI lite review requested due to automatic review settings July 9, 2026 14:49
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

⚠️ Unsafe Code Detected

This PR modifies files containing unsafe Rust code. Extra scrutiny is required during review.

For more on why we check whole files, instead of just diffs, check out the Rustonomicon

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a regression where per-VP IoPool threads (created via ThreadDriverBackend / VmTaskDriverSource::current()) could never exit after guest reset because a thread-local strong DefaultDriver clone kept the pool’s task queue alive indefinitely, leaking threads and IO backend fds.

Changes:

  • Switch the vm_task thread-local CURRENT_DRIVER from a strong DefaultDriver to a weak WeakDefaultDriver, upgrading only transiently when needed.
  • Add IoDriver::downgrade() and WeakIoDriver to pal_async, plus platform type aliases (Weak*Driver / WeakDefaultDriver).
  • Add a regression test ensuring a dedicated pool thread exits once the last strong driver is dropped.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
vm/vmcore/src/vm_task.rs Use weak TLS driver for per-VP pool threads; upgrade on demand; add regression test for thread exit after driver drop.
support/pal/pal_async/src/windows/mod.rs Re-export WeakDefaultDriver for Windows default backend.
support/pal/pal_async/src/windows/iocp.rs Add WeakIocpDriver alias for IoDriver weak refs.
support/pal/pal_async/src/unix/mod.rs Re-export WeakDefaultDriver for epoll/kqueue backends.
support/pal/pal_async/src/unix/kqueue.rs Add WeakKqueueDriver alias for IoDriver weak refs.
support/pal/pal_async/src/unix/epoll.rs Add WeakEpollDriver alias for IoDriver weak refs.
support/pal/pal_async/src/lib.rs Export WeakDefaultDriver from the platform sys module.
support/pal/pal_async/src/io_pool.rs Implement IoDriver::downgrade() and WeakIoDriver::upgrade() to avoid self-referential pool lifetimes.

Comment thread support/pal/pal_async/src/io_pool.rs Outdated
Comment thread support/pal/pal_async/src/io_pool.rs Outdated
@bitranox
Robert Nowotny (bitranox) force-pushed the fix-vp-pool-thread-leak branch 2 times, most recently from 31fefb3 to 303309d Compare July 9, 2026 15:20
Copilot AI review requested due to automatic review settings July 9, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

@benhillis

Copy link
Copy Markdown
Member

Robert Nowotny (@bitranox), this PR looks close but has been inactive for a while. Is it ready for review or merge, or is anything still pending? Thanks.

Copilot AI review requested due to automatic review settings September 22, 2026 16:43
@bitranox

Copy link
Copy Markdown
Contributor Author

Ready from my side, nothing pending.

I re-checked it against current main: the strong CURRENT_DRIVER loan is still in place, so the leak still reproduces. I have rebased the change onto 15c0c6c and force-pushed, so the checks now run against current main rather than the July base it sat on. It applied without conflict, and cargo test -p vmcore -p pal_async passes there, regression test included.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The regression test does not exercise ThreadDriverBackend::build and should cover the production spawn/loan path.

Review effort: Lite
Findings: None

Copilot AI review requested due to automatic review settings September 22, 2026 17:25
@bitranox

Copy link
Copy Markdown
Contributor Author

Agreed, the test did not cover the production path. It built its own pool and hand-wrote the lend sequence, so it only characterised the pattern the test itself wrote.

I confirmed that by reintroducing the strong self-reference in ThreadDriverBackend::build and re-running: the old test still passed against the leak it was named for. The replacement drives build with a target VP, asserts the dedicated thread was taken, then drops the driver and waits for the weak handle to stop upgrading. The pool's thread owns the DefaultPool, so that only happens once the thread has left run() and dropped it. Against the same reintroduced leak the new test fails on its timeout; with the fix in place it passes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The shutdown test does not reliably verify thread completion, and the weak-reference lifetime documentation needs correction.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)

Comment thread vm/vmcore/src/vm_task.rs Outdated
@github-actions

Copy link
Copy Markdown

@bitranox

Copy link
Copy Markdown
Contributor Author

All three failing checks on 8529c9b75 look unrelated to this change.

build openhcl [aarch64-linux] exited 143 in Initialize job after 10 seconds, so the runner went away before anything was built.

run vmm-tests [x64-windows-amd-snp] failed 3 of its 97 tests, all of them Hyper-V backend cases. That lane failed on two other PRs the same day as well (runs 35756683587 and 35752252910).

run vmm-tests [aarch64-linux-tcg] timed out in aarch64_exclusive::openvmm_linux_aarch64_mpidr_affinity_rollover_aarch64_tcg. That test has timed out the same way on three other PRs since 2026-09-10, none of which touches this code:

  • run 34447969604, the IPMI KCS change, FAIL at 643.6s
  • run 35265628214, the OpenTelemetry tracing change, FAIL at 642.3s
  • run 35363444184, the release-commit verification change, FAIL at 641.1s

The shape is identical in all of them. The guest logs an RCU stall, prints reboot: Power down, and a second stall then names a secondary CPU that never answers its NMI. The VM never halts, and the inspect dump collected at the timeout reads power_state: "running" with halt_count: 0. A passing run of the same test logs that first stall too, followed by rcu: INFO: Stall ended during stack backtracing., so the stall is normal here and what varies is whether the stalled CPU comes back. On 15c0c6cb4, the commit this branch is based on, the test passed in 59s (run 35653579564).

This change also cannot reach that VM. The thread local it touches is lent only on a dedicated pool thread, and ThreadDriverBackend::build spawns one only when a caller passes a target VP. The test runs with no vmbus and a single virtio vsock device on a PCIe root, so no such caller exists there and with_driver falls back to the default driver exactly as the old code did.

Could you re-run the aarch64-linux-tcg lane? I do not have permission to re-run it myself.

A device that builds a target-VP task driver (ThreadDriverBackend) spawns a
dedicated IO pool thread and, since the thread-local IO dispatch change, lent
a clone of that pool's own driver into CURRENT_DRIVER for the whole lifetime
of pool.run(). IoPool::run only returns once the last strong IoDriver is
dropped, so that self-reference kept the pool's task queue open forever: after
a device closed its channel and dropped its driver, the thread parked
permanently, never releasing its epoll fd and wakeup eventfd.

A guest reset (reboot) is the steady-state path that repeatedly rebuilds these
per-channel drivers, so every reboot stacked a fresh set of parked threads and
fds. On a Linux guest with VMBus netvsp/storvsp devices this leaked ~132
threads and ~247 fds per reboot until the process hit its fd limit.

Publish a weak reference instead. It is only borrowed by code running on the
pool's own thread while a task is executing, so a strong driver provably still
exists at borrow time and the transient upgrade succeeds; at rest the thread
holds no strong reference, so the pool shuts down and the thread exits once the
device drops its driver. Add WeakIoDriver / IoDriver::downgrade to pal_async
and a regression test that drives ThreadDriverBackend::build and waits for the
pool to release its IO backend.

The test waits on WeakIoDriver::is_backend_alive rather than upgrade.
IoPool::run drops the pool's scheduler on entry, so upgrade starts returning
None as soon as the caller drops its driver, which is shutdown requested and
not shutdown done: measured, it returns None while the dedicated thread is
still inside run(). The pool holds its IO backend until run() returns, so the
backend going away is what shows the fds came back.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved review issues were identified, and regression coverage is included.

Review effort: Lite
Findings: None

Resolved since last review (1)

@github-actions

Copy link
Copy Markdown

@smalis-msft

Copy link
Copy Markdown
Contributor

Robert Nowotny (@bitranox) you should hop in our new Discord, https://aka.ms/openvmmdiscord. Probably a better way to get John Starks (@jstarks)' attention lol

@bitranox

Copy link
Copy Markdown
Contributor Author

Robert Nowotny (Robert Nowotny (@bitranox)) you should hop in our new Discord, https://aka.ms/openvmmdiscord. Probably a better way to get John Starks (John Starks (@jstarks))' attention lol

uh, I dont need attention, just colaboration. the only help I need is the hvci issue - it needs a kvm kernel patch as far as I researched, there seem to be no way around it. For that I really need help, to get that kernel patch into the linux kernel, since I am not a kernel developer. So all I really ask is, to get that done. Everything else I can do on myprivate fork for proxmox, donating it back to this project. I emailed john, unfortunately no answer ...

Comment thread support/pal/pal_async/src/io_pool.rs Outdated
Comment thread vm/vmcore/src/vm_task.rs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor thoughts on the code itself, mostly I think it's fine. I need to think more on if there are any potential issues that could lead to this change dropping things too soon though.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The weak-reference lifecycle fix is consistent across platforms and directly covered by a focused regression test.

Review effort: Balanced
Findings: None

@smalis-msft

Copy link
Copy Markdown
Contributor

AI review finding:

The weak upgrade in vm_task.rs exposes the field-drop order in TaskFuture: _scheduler is dropped before the user’s future.

When the device driver has already been dropped and the last task completes or is cancelled:

Dropping _scheduler releases the last scheduler reference.
The user future’s destructor runs on the dedicated thread.
If that destructor spawns work through a current-thread driver, the weak upgrade fails.
The work silently goes to the default executor, although the dedicated thread is still registered.

I did not find an existing production destructor in the current-driver consumers that triggers this, so this is a verified API-behavior regression—not an observed guest failure.

Suggested fix: place future before _scheduler in TaskFuture, retaining the scheduler until future destruction finishes. I tested this experimentally: it fixes both failing probes, and the thread-leak regression test still passes.

@smalis-msft

Copy link
Copy Markdown
Contributor

More AI:

The earlier field-order fix is incomplete
async-task performs more cleanup after destroying TaskFuture:

It destroys the result of a completed, detached task.
It invokes the completion awaiter’s wake callback.
Both can initiate work through a current-thread driver. When the last scheduler reference has already gone, that work incorrectly falls back to the default executor—even though the callback is executing on the dedicated thread.

Correction to my previous recommendation: moving the scheduler field after the future is not sufficient. The scheduler needs to survive the entire runnable execution, including output destruction and completion notification.

I tested holding a temporary scheduler reference around task.run() in TaskQueue::run. This covers all four completion/cancellation probes while still allowing idle pools to shut down. 186 tests passed with that experimental change.

These extend the existing finding; I would not count them as unrelated defects.

@bitranox

Copy link
Copy Markdown
Contributor Author

More AI:

The earlier field-order fix is incomplete async-task performs more cleanup after destroying TaskFuture:

It destroys the result of a completed, detached task. It invokes the completion awaiter’s wake callback. Both can initiate work through a current-thread driver. When the last scheduler reference has already gone, that work incorrectly falls back to the default executor—even though the callback is executing on the dedicated thread.

Correction to my previous recommendation: moving the scheduler field after the future is not sufficient. The scheduler needs to survive the entire runnable execution, including output destruction and completion notification.

I tested holding a temporary scheduler reference around task.run() in TaskQueue::run. This covers all four completion/cancellation probes while still allowing idle pools to shut down. 186 tests passed with that experimental change.

These extend the existing finding; I would not count them as unrelated defects.

do I need to do smth, or You take care of it ? I am ok with everything when the fd leak is gone ...

@smalis-msft

Steven Malis (smalis-msft) commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

If you could combine that fix with this PR it'd be appreciated. We don't want to risk regressing other functionality.

@bitranox

Copy link
Copy Markdown
Contributor Author

If you could combine that fix with this PR it'd be appreciated. We don't want to risk regressing other functionality.

on it - i will do it over the weekend...

When a task completes or is cancelled, Runnable::run drops its future,
then drops the output if the task was detached, then wakes the task's
awaiter. The scheduler reference held in TaskFuture goes away with the
future. If it was the last one, the two later steps run with the
scheduler already gone, and so does the future's own destructor, since
TaskFuture drops its scheduler field first.

On a dedicated VP pool thread, CurrentThreadDriver finds the pool
through a weak reference. In those teardown steps the upgrade fails and
anything created there falls back to the default driver, even though it
is running on the pool thread.

TaskQueue::run now upgrades the task's scheduler before calling run()
and drops it when run() returns. An idle pool still shuts down: the
extra reference lasts for one call only.

Add a test for each teardown path: a completed future's destructor, a
cancelled future's destructor, a detached task's output destructor and
the awaiter wake. All four fail without the change.

Also remove WeakIoDriver::is_backend_alive, which only the leak test
used, and stop the vm_task tests from polling. Each test now learns that
the pool thread has exited from a thread-local that is dropped when the
thread ends, and waits for its probe report on a channel. The 60 second
timeout only bounds how long a regression takes to fail.
Copilot AI balanced review requested due to automatic review settings October 8, 2026 09:21
@bitranox

Copy link
Copy Markdown
Contributor Author

Folded in as 62bd37b. TaskQueue::run now holds the task's scheduler around task.run(), as you suggested.

There is a test for each of the four teardown paths: a completed future's destructor, a cancelled future's destructor, a detached task's output destructor, and the awaiter wake. Each one checks on the pool thread that the weak upgrade CurrentThreadDriver::with_driver relies on still succeeds. All four fail without the change and pass with it.

The same push also covers your two inline comments: is_backend_alive is gone and the tests no longer depend on timing (details in the threads).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The fix changes cross-platform executor shutdown and concurrent task-destruction lifetime semantics, warranting final human review.

0 open findings

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

The tests waited for the pool thread's exit signal and for the probe
reports with a 60 second timeout. Block on the channels instead and
leave a wedged test to the test runner. A signal that is dropped without
being sent still fails the test through expect.
Copilot AI balanced review requested due to automatic review settings October 8, 2026 09:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The lifecycle fix is coherent across platforms and is covered by targeted regression tests.

0 open findings

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@smalis-msft
Steven Malis (smalis-msft) merged commit 13660c2 into microsoft:main Oct 9, 2026
76 of 77 checks passed
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

@bitranox
Robert Nowotny (bitranox) deleted the fix-vp-pool-thread-leak branch October 9, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

unsafe Related to unsafe code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants