Splitting a tokio-test Mock instance can cause deadlock/lockup
Using a tokio_test::io::Mock to read and write simultaneously via tokio::io::split causes lockup if the Mock is waiting, and there's no data to read.
This is a simple test, enough to trigger it for me:
#[tokio::test]
async fn check_io() {
let socket = tokio_test::io::Builder::new()
.wait(Duration::from_millis(10))
.write([0].as_slice())
.build();
let (mut recv, mut send) = tokio::io::split(socket);
tokio::spawn(async move { recv.read_u8().await.unwrap() });
send.write_u8(0).await.unwrap();
}
I'm not super familiar with the Mock API, but I'd expect this to finish after 10ms, possibly with an error due to the extra read. Instead, it hangs forever.
As far as I'm aware, this should not be a problem unless the Mock is blocking in a poll call of some sort, which may be a deeper issue?
Version
├── tokio v1.45.0
│ └── tokio-macros v2.5.0 (proc-macro)
└── tokio-openssl v0.6.5
└── tokio v1.45.0 (*)
└── tokio v1.45.0 (*)
├── tokio v1.45.0 (*)
└── tokio-util v0.7.15
└── tokio v1.45.0 (*)
├── tokio v1.45.0 (*)
└── tokio-test v0.4.4
├── tokio v1.45.0 (*)
└── tokio-stream v0.1.17
└── tokio v1.45.0 (*)
Platform
Linux [...] 5.15.0-139-generic #149~20.04.1-Ubuntu SMP [...] x86_64 x86_64 x86_64 GNU/Linux
https://github.com/tokio-rs/tokio/blob/ab3ff69cf2258a8c696b2dca89a2cef4ff114c1c/tokio-test/src/io.rs#L360-L368
https://github.com/tokio-rs/tokio/blob/ab3ff69cf2258a8c696b2dca89a2cef4ff114c1c/tokio-test/src/io.rs#L408-L416
poll_read and poll_write poll the exact same Sleep, so we can consider this timeline.
-
send.write_u8(0).await -
poll_writeregisters a timer. -
poll_writepolls the registeredSleep, and its waker is registered. -
poll_writereturnsPoll::Pendingand is suspended. -
recv.read_u8().await -
poll_readpolls the exact sameSleep, and replaces thepoll_write's waker withpoll_read's waker. - Now,
poll_writelost its waker. - Runtime fires
Sleepand wakes up thepoll_read, but nothing to read. -
poll_readhangs forever. -
poll_writealso hangs forever because it lost its waker.
If we go back to the docs.
Sequence a wait.
The next operation in the mock’s script will be to wait without doing so for duration amount of time.
The 'next operation' should either be poll_write or poll_read, otherwise, the API design might surprise the downstream developer.
I think this is a bug, poll_read and poll_write should not poll the same Sleep.
i was suggested to add a warning to split, what do you think of the following:
/// # Warning
///
/// Using split with certain mock implementations (such as tokio_test::io::Mock)
/// may cause deadlocks if the mock is configured to wait and both halves are used
/// simultaneously. This occurs because both halves share the same underlying stream
/// protected by a Mutex, and concurrent operations can lead to lock contention
/// with waiting mock states.
///
/// Consider using alternatives like tokio::io::duplex() for testing scenarios
/// that require simultaneous read/write operations.
i was suggested to add a warning to split, what do you think of the following: /// # Warning /// /// Using
splitwith certain mock implementations (such astokio_test::io::Mock) /// may cause deadlocks if the mock is configured to wait and both halves are used /// simultaneously. This occurs because both halves share the same underlying stream /// protected by aMutex, and concurrent operations can lead to lock contention /// with waiting mock states. /// /// Consider using alternatives liketokio::io::duplex()for testing scenarios /// that require simultaneous read/write operations.
@redjonzaci These kind of warning is hard for people who is not familiar with tokio internal to understand. However, at least, we can add a short warning on the docs of tokio_test::io::Mock, just something like "Using spilit for Mock may cause deadlock, please don't do it".
i just want us to agree on wording of the warning and i can add it in a PR, so we can then close this issue