Skip to content

io: always cleanup AsyncFd registration list on deregister#7773

Merged
Darksonn merged 36 commits intotokio-rs:masterfrom
F4RAN:7563-fix-asyncfd-leak
Jan 14, 2026
Merged

io: always cleanup AsyncFd registration list on deregister#7773
Darksonn merged 36 commits intotokio-rs:masterfrom
F4RAN:7563-fix-asyncfd-leak

Conversation

@F4RAN
Copy link
Copy Markdown
Contributor

@F4RAN F4RAN commented Dec 12, 2025

Fixes memory leak when fd is closed before AsyncFd drop.

Fixes: #7563

Motivation

When a file descriptor is closed before dropping AsyncFd, OS deregistration fails and causes an early return, preventing cleanup of the internal registration list. This leaks ScheduledIo objects over time.

Solution

Always clean up the internal registration list, even if OS deregistration fails. Store the OS result, perform cleanup, then return the error. This is safe because the list is Tokio's internal tracking - if OS deregister fails, the OS already doesn't track it, so cleanup doesn't break memory safety.

Includes a test reproducing the bug scenario.

Copy link
Copy Markdown
Member

@ADD-SP ADD-SP left a comment

Choose a reason for hiding this comment

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

if OS deregister fails, the OS already doesn't track it, so cleanup doesn't break memory safety.

Could you please explain why? I'm curious on it.

Copy link
Copy Markdown
Member

@ADD-SP ADD-SP left a comment

Choose a reason for hiding this comment

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

Only cleaning up the intrusive list may not be a proper fix of this issue. There are some questions we need to answer.

  • Shall we propagate the error to the downstream user?
  • When / When not propagate the error?
  • How to propagate this error?
  • Will our fix cause a breaking change?

@ADD-SP ADD-SP added A-tokio Area: The main tokio crate S-waiting-on-author Status: awaiting some action (such as code changes) from the PR or issue author. M-io Module: tokio/io labels Dec 13, 2025
@F4RAN
Copy link
Copy Markdown
Contributor Author

F4RAN commented Dec 13, 2025

Hi @ADD-SP,

Thanks for the feedback! I've updated the PR to address your questions.

Test Infrastructure Added

Added test-only methods to verify the fix:

  • registration_set.rs: pending_release_count() and total_registration_count() (needed since LinkedList has no len())
  • driver.rs: Wrapper methods
  • handle.rs: Methods to expose counts for integration tests

All gated with #[cfg(feature = "full")] and #[doc(hidden)]. Kept /// TEST PURPOSE RELATED TO PR #7773 comments for easy removal later.

Test Evolution

Initial test only exercised the code path. Updated to check registration count after each iteration and assert it doesn't grow (indicating leak).

Platform-Specific Behavior

The leak cannot be reproduced on macOS. On Linux with epoll:

  • With buggy code: Test fails, detecting 30 leaked ScheduledIo objects
  • With fix: Test passes, count stays at baseline

The test is Linux-specific for leak detection but exercises the code path on macOS too.

Why Cleanup After OS Failure is Safe

if OS deregister fails, the OS already doesn't track it, so cleanup doesn't break memory safety.

Could you please explain why? I'm curious on it.

When mio::Registry::deregister() fails (typically EBADF for closed fds), the OS kernel already doesn't track this fd in its polling mechanism (epoll/kqueue). The ScheduledIo in Tokio's internal registrations list is just our bookkeeping - removing it doesn't affect the kernel or other resources. Since the OS doesn't know about the fd anymore, cleaning up our internal tracking is safe and doesn't break memory safety.

Remaining Questions

Only cleaning up the intrusive list may not be a proper fix of this issue. There are some questions we need to answer.

  • Shall we propagate the error to the downstream user?
  • When / When not propagate the error?
  • How to propagate this error?
  • Will our fix cause a breaking change?

Per your comment in #7563, I see two options for into_inner(): (1)add try_into_inner() (non-breaking) or (2) panic on error. I recommend Option 1 for non-breaking behavior and user choice, but I can implement Option 2 if you prefer.

  1. Shall we propagate the error to the downstream user?
    Yes, but make it optional via try_into_inner() so users can choose.
  2. When / When not propagate the error?
    Not in Drop (can't return errors), but yes in into_inner() via optional try_into_inner() while keeping into_inner() ignoring errors for compatibility.
  3. How to propagate this error?
    Add try_into_inner() -> Result<T, (T, io::Error)> (Option 1).
  4. Will our fix cause a breaking change?
    No.

Thanks again!

@F4RAN F4RAN requested review from ADD-SP and martin-g December 16, 2025 17:24
@F4RAN
Copy link
Copy Markdown
Contributor Author

F4RAN commented Jan 1, 2026

Can Miri or address sanitizer capture this memory leak?
Hi again @ADD-SP,
Thanks for your idea.

I'll be appreciated if you check my rss-based test:

Summary

I tested several approaches as suggested:

Approach Result
LeakSanitizer ❌ Cannot detect - objects are still reachable via internal linked list (logical leak, not unreachable memory)
Miri ❌ Cannot run - requires system calls (epoll_ctl, libc::close, sockets)

Since this is a "logical leak" (objects stuck in a data structure, not truly unreachable), standard leak detectors don't work.

Solution: RSS-based memory test

I implemented a test that monitors RSS (Resident Set Size) before and after running 5000 iterations. No internal API access needed - just reads /proc/self/statm.

Linux results:

  • Without fix: FAILED - RSS grew by 1920KB (leak detected!)
  • With fix: PASSED - RSS stable

The test is gated with #[cfg(target_os = "linux")] since it reads /proc/self/statm.

F4RAN added 2 commits January 1, 2026 09:36
Instead of checking absolute RSS growth (which varies with allocator
behavior), this test now runs multiple phases and checks if memory
stabilizes. A real leak causes unbounded growth across all phases;
fixed code stabilizes as memory is reused.

This approach is more robust across different CI environments where
allocator behavior may differ.
@F4RAN F4RAN force-pushed the 7563-fix-asyncfd-leak branch from cc2358c to 3b8d349 Compare January 1, 2026 06:29
@Darksonn
Copy link
Copy Markdown
Member

Darksonn commented Jan 1, 2026

Miri does support epoll.

F4RAN and others added 2 commits January 2, 2026 07:45
@F4RAN
Copy link
Copy Markdown
Contributor Author

F4RAN commented Jan 2, 2026

Miri does support epoll.

Hi @Darksonn.
I tested with Miri on Linux. Both with and without the fix, Miri reports no leak:

test memory_leak_miri_check ... ok
Miri detects unreachable memory, but this is a logical leak - the ScheduledIo objects remain reachable through the internal registrations linked list. They're not orphaned allocations, they're just never removed when the fd is closed before drop. Same reason LSan couldn't detect it. The RSS-based test remains the practical way to verify this fix works - it detects unbounded memory growth across multiple phases.

@F4RAN F4RAN requested a review from Darksonn January 2, 2026 04:51
@Darksonn
Copy link
Copy Markdown
Member

Darksonn commented Jan 2, 2026

Miri detects unreachable memory

Are you saying that miri detects it, and that miri doesn't detect it when the fix is applied? That sounds like a pretty good way to test it.

@F4RAN
Copy link
Copy Markdown
Contributor Author

F4RAN commented Jan 2, 2026

Miri detects unreachable memory

Are you saying that miri detects it, and that miri doesn't detect it when the fix is applied? That sounds like a pretty good way to test it.

To clarify: Miri cannot detect this leak - neither with nor without the fix. Miri is designed to detect unreachable memory (orphaned allocations). But this is a logical leak where objects remain reachable through the internal registrations linked list - they're just never removed. Since they're still reachable, Miri sees no problem. Same reason LSan couldn't detect it. The RSS-based test is the practical solution since it measures actual memory growth.

Both with and without the fix, Miri reports no leak

@Darksonn
Copy link
Copy Markdown
Member

Darksonn commented Jan 2, 2026

Relying on RSS is going to be extremely fragile. Your memory allocator may keep memory around even if the program has freed it, and this still counts in RSS.

If we're going the route of measuring memory, then please declare a #[global_allocator] that increments/decrements a static AtomicUsize for tracking the memory actually in use (it may forward calls into the system allocator to perform actual allocation), and use this to measure the amount instead.

The test needs to be moved to its own file so the #[global_allocator] does not affect other tests.

@F4RAN
Copy link
Copy Markdown
Contributor Author

F4RAN commented Jan 5, 2026

Relying on RSS is going to be extremely fragile. Your memory allocator may keep memory around even if the program has freed it, and this still counts in RSS.

If we're going the route of measuring memory, then please declare a #[global_allocator] that increments/decrements a static AtomicUsize for tracking the memory actually in use (it may forward calls into the system allocator to perform actual allocation), and use this to measure the amount instead.

The test needs to be moved to its own file so the #[global_allocator] does not affect other tests.

@Darksonn, Implemented a custom #[global_allocator] that tracks allocations via a static AtomicUsize, forwarding to the system allocator. The test is in io_async_fd_memory_leak.rs so the allocator only affects that file.
Verified on Linux: without the fix, the test fails with ~256KB growth per phase; with the fix, it passes with stable memory.

Copy link
Copy Markdown
Member

@Darksonn Darksonn left a comment

Choose a reason for hiding this comment

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

Thanks for adding the new test. One nit below.

F4RAN added 2 commits January 5, 2026 12:28
- Add feature flag check to #[cfg]
- Make allocated counter a struct field
- Add error handling for fcntl in set_nonblocking
@F4RAN F4RAN requested review from Darksonn and martin-g January 5, 2026 09:03
@Darksonn Darksonn merged commit 1280cf8 into tokio-rs:master Jan 14, 2026
88 checks passed
jimsynz pushed a commit to jimsynz/neonfs that referenced this pull request Mar 6, 2026
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [tokio](https://tokio.rs) ([source](https://github.com/tokio-rs/tokio)) | dependencies | minor | `1.49.0` → `1.50.0` |

---

### Release Notes

<details>
<summary>tokio-rs/tokio (tokio)</summary>

### [`v1.50.0`](https://github.com/tokio-rs/tokio/releases/tag/tokio-1.50.0): Tokio v1.50.0

[Compare Source](tokio-rs/tokio@tokio-1.49.0...tokio-1.50.0)

### 1.50.0 (Mar 3rd, 2026)

##### Added

- net: add `TcpStream::set_zero_linger` ([#&#8203;7837])
- rt: add `is_rt_shutdown_err` ([#&#8203;7771])

##### Changed

- io: add optimizer hint that `memchr` returns in-bounds pointer ([#&#8203;7792])
- io: implement vectored writes for `write_buf` ([#&#8203;7871])
- runtime: panic when `event_interval` is set to 0 ([#&#8203;7838])
- runtime: shorten default thread name to fit in Linux limit ([#&#8203;7880])
- signal: remember the result of `SetConsoleCtrlHandler` ([#&#8203;7833])
- signal: specialize windows `Registry` ([#&#8203;7885])

##### Fixed

- io: always cleanup `AsyncFd` registration list on deregister ([#&#8203;7773])
- macros: remove (most) local `use` declarations in `tokio::select!` ([#&#8203;7929])
- net: fix `GET_BUF_SIZE` constant for `target_os = "android"` ([#&#8203;7889])
- runtime: avoid redundant unpark in current\_thread scheduler ([#&#8203;7834])
- runtime: don't park in `current_thread` if `before_park` defers waker ([#&#8203;7835])
- io: fix write readiness on ESP32 on short writes ([#&#8203;7872])
- runtime: wake deferred tasks before entering `block_in_place` ([#&#8203;7879])
- sync: drop rx waker when oneshot receiver is dropped ([#&#8203;7886])
- runtime: fix double increment of `num_idle_threads` on shutdown ([#&#8203;7910], [#&#8203;7918], [#&#8203;7922])

##### Unstable

- fs: check for io-uring opcode support ([#&#8203;7815])
- runtime: avoid lock acquisition after uring init ([#&#8203;7850])

##### Documented

- docs: update outdated unstable features section ([#&#8203;7839])
- io: clarify the behavior of `AsyncWriteExt::shutdown()` ([#&#8203;7908])
- io: explain how to flush stdout/stderr ([#&#8203;7904])
- io: fix incorrect and confusing `AsyncWrite` documentation ([#&#8203;7875])
- rt: clarify the documentation of `Runtime::spawn` ([#&#8203;7803])
- rt: fix missing quotation in docs ([#&#8203;7925])
- runtime: correct the default thread name in docs ([#&#8203;7896])
- runtime: fix `event_interval` doc ([#&#8203;7932])
- sync: clarify RwLock fairness documentation ([#&#8203;7919])
- sync: clarify that `recv` returns `None` once closed and no more messages ([#&#8203;7920])
- task: clarify when to use `spawn_blocking` vs dedicated threads ([#&#8203;7923])
- task: doc that task drops before `JoinHandle` completion ([#&#8203;7825])
- signal: guarantee that listeners never return `None` ([#&#8203;7869])
- task: fix task module feature flags in docs ([#&#8203;7891])
- task: fix two typos ([#&#8203;7913])
- task: improve the docs of `Builder::spawn_local` ([#&#8203;7828])
- time: add docs about auto-advance and when to use sleep ([#&#8203;7858])
- util: fix typo in docs ([#&#8203;7926])

[#&#8203;7771]: tokio-rs/tokio#7771

[#&#8203;7773]: tokio-rs/tokio#7773

[#&#8203;7792]: tokio-rs/tokio#7792

[#&#8203;7803]: tokio-rs/tokio#7803

[#&#8203;7815]: tokio-rs/tokio#7815

[#&#8203;7825]: tokio-rs/tokio#7825

[#&#8203;7828]: tokio-rs/tokio#7828

[#&#8203;7833]: tokio-rs/tokio#7833

[#&#8203;7834]: tokio-rs/tokio#7834

[#&#8203;7835]: tokio-rs/tokio#7835

[#&#8203;7837]: tokio-rs/tokio#7837

[#&#8203;7838]: tokio-rs/tokio#7838

[#&#8203;7839]: tokio-rs/tokio#7839

[#&#8203;7850]: tokio-rs/tokio#7850

[#&#8203;7858]: tokio-rs/tokio#7858

[#&#8203;7869]: tokio-rs/tokio#7869

[#&#8203;7871]: tokio-rs/tokio#7871

[#&#8203;7872]: tokio-rs/tokio#7872

[#&#8203;7875]: tokio-rs/tokio#7875

[#&#8203;7879]: tokio-rs/tokio#7879

[#&#8203;7880]: tokio-rs/tokio#7880

[#&#8203;7885]: tokio-rs/tokio#7885

[#&#8203;7886]: tokio-rs/tokio#7886

[#&#8203;7889]: tokio-rs/tokio#7889

[#&#8203;7891]: tokio-rs/tokio#7891

[#&#8203;7896]: tokio-rs/tokio#7896

[#&#8203;7904]: tokio-rs/tokio#7904

[#&#8203;7908]: tokio-rs/tokio#7908

[#&#8203;7910]: tokio-rs/tokio#7910

[#&#8203;7913]: tokio-rs/tokio#7913

[#&#8203;7918]: tokio-rs/tokio#7918

[#&#8203;7919]: tokio-rs/tokio#7919

[#&#8203;7920]: tokio-rs/tokio#7920

[#&#8203;7922]: tokio-rs/tokio#7922

[#&#8203;7923]: tokio-rs/tokio#7923

[#&#8203;7925]: tokio-rs/tokio#7925

[#&#8203;7926]: tokio-rs/tokio#7926

[#&#8203;7929]: tokio-rs/tokio#7929

[#&#8203;7932]: tokio-rs/tokio#7932

</details>

---

### Configuration

📅 **Schedule**: Branch creation - Between 12:00 AM and 03:59 AM ( * 0-3 * * * ) in timezone Pacific/Auckland, Automerge - Between 12:00 AM and 03:59 AM ( * 0-3 * * * ) in timezone Pacific/Auckland.

🚦 **Automerge**: Disabled because a matching PR was automerged previously.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41Mi4xIiwidXBkYXRlZEluVmVyIjoiNDMuNTIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsicmVub3ZhdGUiXX0=-->

Reviewed-on: https://harton.dev/project-neon/neonfs/pulls/53
Co-authored-by: Renovate Bot <bot@harton.nz>
Co-committed-by: Renovate Bot <bot@harton.nz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-tokio Area: The main tokio crate M-io Module: tokio/io

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The AsyncFd in Tokio has a serious memory leak issue.

5 participants