diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d351f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + RUST_BACKTRACE: 1 + RUSTUP_MAX_RETRIES: 10 + RUSTFLAGS: "-D warnings" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: taiki-e/install-action@v2 + with: + tool: just@1.40.0 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: "v1" + cache-workspace-crates: true + cache-on-failure: true + workspaces: | + . + - name: Compile + run: just cargo-compile + - name: Clippy + run: just cargo-clippy-check + - name: Rustfmt + run: just cargo-fmt-check + - name: Run tests + run: just test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dfc9f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/target-ra +.envrc diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8c4f23d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,79 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "absurd-future" +version = "0.1.0" +dependencies = [ + "anyhow", + "tokio", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e1b5ba9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "absurd-future" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "A future adapter that turns a future that never resolves (returns Infallible) into a future that can resolve to any type." +repository = "https://github.com/user/absurd-future" +keywords = ["future", "async", "infallible", "never"] +categories = ["asynchronous"] + +[dependencies] + +[dev-dependencies] +tokio = { version = "1.48.0", features = ["rt", "time", "macros", "rt-multi-thread"] } +anyhow = "1.0.100" diff --git a/README.md b/README.md index e1daccb..e625225 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ # absurd-future + +[![crates.io](https://img.shields.io/crates/v/absurd-future.svg)](https://crates.io/crates/absurd-future) +[![docs.rs](https://docs.rs/absurd-future/badge.svg)](https://docs.rs/absurd-future) + +A future adapter that turns a future that never resolves (i.e., returns `Infallible`) into a future that can resolve to any type. + +This is useful in scenarios where you have a task that runs forever (like a background service) but need to integrate it into an API that expects a specific return type, such as `tokio::task::JoinSet`. + +For a detailed explanation of the motivation behind this crate and the concept of uninhabited types in Rust async code, see the blog post: [How to use Rust's never type (!) to write cleaner async code](https://academy.fpblock.com/blog/rust-never-type-async-code). + +## Usage + +For a complete, runnable example of how to use this crate with `tokio::task::JoinSet`, please see the example file: [`examples/tokio.rs`](./examples/tokio.rs). + +## License + +This project is licensed under the MIT license. diff --git a/examples/tokio.rs b/examples/tokio.rs new file mode 100644 index 0000000..b2a301b --- /dev/null +++ b/examples/tokio.rs @@ -0,0 +1,57 @@ +use absurd_future::absurd_future; +use anyhow::{bail, Result}; +use std::{convert::Infallible, time::Duration}; + +use tokio::task::JoinSet; + +#[tokio::main] +async fn main() -> Result<()> { + let _result = main_inner().await?; + Ok(()) +} + +async fn task_one() -> Infallible { + loop { + println!("Hello from task 1"); + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +async fn task_two() -> Result { + let mut counter = 1; + loop { + println!("Hello from task 2"); + counter += 1; + tokio::time::sleep(Duration::from_secs(1)).await; + if counter >= 3 { + bail!("Counter is >= 3") + } + } +} + +async fn main_inner() -> Result { + let mut join_set = JoinSet::new(); + + join_set.spawn(absurd_future(task_one())); + join_set.spawn(task_two()); + + match join_set.join_next().await { + Some(result) => match result { + Ok(res) => match res { + Ok(_res) => bail!("Impossible: Infallible witnessed!"), + Err(e) => { + join_set.abort_all(); + bail!("Task exited with {e}") + } + }, + Err(e) => { + join_set.abort_all(); + bail!("Task exited with {e}") + } + }, + None => { + join_set.abort_all(); + bail!("No tasks found in task set") + } + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..0abd648 --- /dev/null +++ b/justfile @@ -0,0 +1,23 @@ +# List all recipes +default: + just --list --unsorted + +# Run example +example-tokio: + cargo run --example tokio + +# cargo compile +cargo-compile: + cargo test --workspace --no-run --locked + +# Clippy check +cargo-clippy-check: + cargo clippy --no-deps --workspace --locked --tests --benches --examples -- -Dwarnings + +# Rustfmt check +cargo-fmt-check: + cargo fmt --all --check + +# Test +test: + -cargo run --example tokio diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ff100ed --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.90.0" diff --git a/src/LICENSE b/src/LICENSE new file mode 100644 index 0000000..ffa8716 --- /dev/null +++ b/src/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FP Block + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1615b52 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,106 @@ +//! A future adapter that turns a future that never resolves (i.e., returns `Infallible`) +//! into a future that can resolve to any type. +//! +//! This is useful in scenarios where you have a task that runs forever (like a background +//! service) but need to integrate it into an API that expects a specific return type, +//! such as `tokio::task::JoinSet`. +//! +//! The core of this crate is the [`AbsurdFuture`] struct and the convenient +//! [`absurd_future`] function. +//! +//! For a detailed explanation of the motivation behind this crate and the concept of +//! uninhabited types in Rust async code, see the blog post: +//! [How to use Rust's never type (!) to write cleaner async code](https://academy.fpblock.com/blog/rust-never-type-async-code). +//! +//! # Example +//! +//! ``` +//! use std::convert::Infallible; +//! use std::future; +//! use absurd_future::absurd_future; +//! +//! // A future that never completes. +//! async fn task_that_never_returns() -> Infallible { +//! loop { +//! // In a real scenario, this might be `tokio::time::sleep` or another +//! // future that never resolves. For this example, we'll just pend forever. +//! future::pending::<()>().await; +//! } +//! } +//! +//! async fn main() { +//! // We have a task that never returns, but we want to use it in a +//! // context that expects a `Result<(), &str>`. +//! let future = task_that_never_returns(); +//! +//! // Wrap it with `absurd_future` to change its output type. +//! let adapted_future: _ = absurd_future::<_, Result<(), &str>>(future); +//! +//! // This adapted future will now pend forever, just like the original, +//! // but its type signature satisfies the requirement. +//! } +//! ``` + +use std::{ + convert::Infallible, + future::Future, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +/// Turn a never-returning future into a future yielding any desired type. +/// +/// This struct is created by the [`absurd_future`] function. +/// +/// Useful for async tasks that logically don't complete but need to satisfy an +/// interface expecting a concrete output type. Because the inner future never +/// resolves, this future will also never resolve, so the output type `T` is +/// never actually produced. +#[must_use = "futures do nothing unless polled"] +pub struct AbsurdFuture { + inner: Pin>, + _marker: PhantomData T>, +} + +impl AbsurdFuture { + /// Creates a new `AbsurdFuture` that wraps the given future. + /// + /// The inner future must have an output type of `Infallible`. + pub fn new(inner: F) -> Self { + Self { + inner: Box::pin(inner), + _marker: PhantomData, + } + } +} + +impl Future for AbsurdFuture +where + F: Future, +{ + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let inner = self.get_mut().inner.as_mut(); + match Future::poll(inner, cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(never) => match never {}, + } + } +} + +/// Wraps a future that never returns and gives it an arbitrary output type. +/// +/// This function makes it easier to create an [`AbsurdFuture`]. +/// +/// # Type Parameters +/// +/// - `F`: The type of the inner future, which must return `Infallible`. +/// - `T`: The desired output type for the wrapped future. This is often inferred. +pub fn absurd_future(future: F) -> AbsurdFuture +where + F: Future, +{ + AbsurdFuture::new(future) +}