Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
931635d
io: always cleanup AsyncFd registration list on deregister
F4RAN Dec 12, 2025
3d7d87d
fix: formatter issues
F4RAN Dec 12, 2025
b6452c7
test: in linux environment
F4RAN Dec 13, 2025
bf5c706
test: linux test with fix
F4RAN Dec 13, 2025
e659d60
chore: remove additional method
F4RAN Dec 13, 2025
b44e56d
chore: remove additional debug comments
F4RAN Dec 13, 2025
94f46b9
fix:formatter
F4RAN Dec 13, 2025
0a0f94e
fix: style: fix clippy warnings and format code
F4RAN Dec 13, 2025
c4141e3
Update tokio/src/runtime/io/registration_set.rs
F4RAN Dec 15, 2025
125dc5d
Update tokio/src/runtime/io/driver.rs
F4RAN Dec 15, 2025
4d30241
Merge branch 'master' into 7563-fix-asyncfd-leak
F4RAN Dec 15, 2025
45d3da2
fix: remove additional imports
F4RAN Dec 15, 2025
ab770fa
restore AsyncFd::try_with_interest()
F4RAN Dec 15, 2025
e36a489
style: run formatter
F4RAN Dec 15, 2025
0b0495f
test(internals): gate test-only APIs behind __internal_test (no publi…
F4RAN Dec 16, 2025
08d5645
fix: spelling error is solved using backticks
F4RAN Dec 16, 2025
e3969ed
fix: rename __internal_test to integration_test
F4RAN Dec 16, 2025
580f197
Merge branch 'master' into 7563-fix-asyncfd-leak
F4RAN Dec 16, 2025
872097a
fix: turn from integration_test to tokio_unstable
F4RAN Dec 29, 2025
0fbd65d
Merge branch 'master' into 7563-fix-asyncfd-leak
F4RAN Dec 29, 2025
a7895ac
test: revert to buggy code
F4RAN Jan 1, 2026
87864bf
test: lsan only test
F4RAN Jan 1, 2026
23d5e04
test: heap profiling
F4RAN Jan 1, 2026
1c1cdd7
test: revert fix to check the test again
F4RAN Jan 1, 2026
23344ce
fix: rss test is applied and works
F4RAN Jan 1, 2026
abd4d91
fix: resolve clippy format
F4RAN Jan 1, 2026
3b8d349
test: improve RSS memory leak test with stabilization approach
F4RAN Jan 1, 2026
3c18df5
Merge branch 'master' into 7563-fix-asyncfd-leak
F4RAN Jan 1, 2026
3eba842
Update tokio/src/runtime/io/driver.rs
F4RAN Jan 2, 2026
ffe88ce
fix: additional line in Cargo file
F4RAN Jan 2, 2026
b8ef07c
test: add custom allocator memory leak test for issue #7563
F4RAN Jan 5, 2026
d84af1c
test: revert to check in linux machine
F4RAN Jan 5, 2026
6fa3271
test: fix test
F4RAN Jan 5, 2026
fdf771e
fix: inline format args to satisfy clippy
F4RAN Jan 5, 2026
9879ccc
test: address review nits for io_async_fd_memory_leak test
F4RAN Jan 5, 2026
2977a5f
fix: allocation problem
F4RAN Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions tokio/src/runtime/io/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ impl Handle {
source: &mut impl Source,
) -> io::Result<()> {
// Deregister the source with the OS poller **first**
self.registry.deregister(source)?;
// Cleanup ALWAYS happens
let os_result = self.registry.deregister(source);

if self
.registrations
Expand All @@ -307,7 +308,7 @@ impl Handle {

self.metrics.dec_fd_count();

Ok(())
os_result // Return error after cleanup
}

fn release_pending_registrations(&self) {
Expand Down
188 changes: 188 additions & 0 deletions tokio/tests/io_async_fd_memory_leak.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Regression test for issue #7563 - Memory leak when fd closed before AsyncFd drop
//!
//! This test uses a custom global allocator to track actual memory usage,
//! avoiding false positives from RSS measurements which include freed-but-retained memory.

#![cfg(all(unix, target_os = "linux"))]

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

/// A tracking allocator that counts bytes currently allocated
struct TrackingAllocator;

static ALLOCATED: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for TrackingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc(layout) };
if !ptr.is_null() {
ALLOCATED.fetch_add(layout.size(), Ordering::Relaxed);
}
ptr
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) };
ALLOCATED.fetch_sub(layout.size(), Ordering::Relaxed);
}

unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
if !new_ptr.is_null() {
// Subtract old size, add new size
if new_size > layout.size() {
ALLOCATED.fetch_add(new_size - layout.size(), Ordering::Relaxed);
} else {
ALLOCATED.fetch_sub(layout.size() - new_size, Ordering::Relaxed);
}
}
new_ptr
}
}

#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;

fn allocated_bytes() -> usize {
ALLOCATED.load(Ordering::Relaxed)
}

#[tokio::test]
async fn memory_leak_when_fd_closed_before_drop() {
use nix::sys::socket::{self, AddressFamily, SockFlag, SockType};
use std::os::unix::io::{AsRawFd, RawFd};
use std::sync::Arc;
use tokio::io::unix::AsyncFd;

struct RawFdWrapper {
fd: RawFd,
}

impl AsRawFd for RawFdWrapper {
fn as_raw_fd(&self) -> RawFd {
self.fd
}
}

struct ArcFd(Arc<RawFdWrapper>);

impl AsRawFd for ArcFd {
fn as_raw_fd(&self) -> RawFd {
self.0.as_raw_fd()
}
}

fn set_nonblocking(fd: RawFd) {
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}

// Warm up - let runtime and allocator stabilize
for _ in 0..100 {
tokio::task::yield_now().await;
}

const ITERATIONS: usize = 1000;

// Phase 1: Warm up allocations
for _ in 0..ITERATIONS {
let (fd_a, _fd_b) = socket::socketpair(
AddressFamily::Unix,
SockType::Stream,
None,
SockFlag::empty(),
)
.unwrap();

let raw_fd = fd_a.as_raw_fd();
set_nonblocking(raw_fd);
std::mem::forget(fd_a);

let wrapper = Arc::new(RawFdWrapper { fd: raw_fd });
let async_fd = AsyncFd::new(ArcFd(wrapper)).unwrap();

// Close fd before dropping AsyncFd - this triggers the bug
unsafe {
libc::close(raw_fd);
}

drop(async_fd);
}

// Let things settle
tokio::task::yield_now().await;
let baseline = allocated_bytes();

// Phase 2: Run more iterations and check for growth
for _ in 0..ITERATIONS {
let (fd_a, _fd_b) = socket::socketpair(
AddressFamily::Unix,
SockType::Stream,
None,
SockFlag::empty(),
)
.unwrap();

let raw_fd = fd_a.as_raw_fd();
set_nonblocking(raw_fd);
std::mem::forget(fd_a);

let wrapper = Arc::new(RawFdWrapper { fd: raw_fd });
let async_fd = AsyncFd::new(ArcFd(wrapper)).unwrap();

unsafe {
libc::close(raw_fd);
}

drop(async_fd);
}

tokio::task::yield_now().await;
let after_phase2 = allocated_bytes();

// Phase 3: Run even more iterations
for _ in 0..ITERATIONS {
let (fd_a, _fd_b) = socket::socketpair(
AddressFamily::Unix,
SockType::Stream,
None,
SockFlag::empty(),
)
.unwrap();

let raw_fd = fd_a.as_raw_fd();
set_nonblocking(raw_fd);
std::mem::forget(fd_a);

let wrapper = Arc::new(RawFdWrapper { fd: raw_fd });
let async_fd = AsyncFd::new(ArcFd(wrapper)).unwrap();

unsafe {
libc::close(raw_fd);
}

drop(async_fd);
}

tokio::task::yield_now().await;
let after_phase3 = allocated_bytes();

let growth_phase2 = after_phase2.saturating_sub(baseline);
let growth_phase3 = after_phase3.saturating_sub(after_phase2);

// If there's a leak, each phase adds ~250KB (1000 * ~256 bytes per ScheduledIo)
// If fixed, memory should stabilize (minimal growth between phases)
// Allow 64KB tolerance for normal allocation variance
let threshold = 64 * 1024; // 64KB

assert!(
growth_phase2 < threshold || growth_phase3 < threshold,
"Memory leak detected: allocations keep growing without stabilizing. \
Phase 1->2: +{growth_phase2} bytes, Phase 2->3: +{growth_phase3} bytes. \
(baseline: {baseline} bytes, phase2: {after_phase2} bytes, phase3: {after_phase3} bytes). \
Expected at least one phase with <{threshold} bytes growth.",
);
}