Skip to content

feat: add opt-in synchronous future bridge - #153

Merged
tisonkun merged 8 commits into
apache:mainfrom
jiengup:feat-block-on
Aug 19, 2026
Merged

feat: add opt-in synchronous future bridge#153
tisonkun merged 8 commits into
apache:mainfrom
jiengup:feat-block-on

Conversation

@jiengup

@jiengup jiengup commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Add an opt-in asyncband::blocking::FutureExt interoperability utility for synchronous Rust code that needs to wait on one runtime-agnostic future, either indefinitely or for a bounded duration.

Closes #147.

Public API

The blocking Cargo feature is disabled by default and has no normal dependencies. Its complete public waiting surface consists of two FutureExt methods:

  • FutureExt::block_on() -> Output for an indefinite wait.
  • FutureExt::wait_timeout(Duration) -> Option<Output> for a bounded wait.
use std::time::Duration;

use asyncband::blocking::FutureExt as _;

assert_eq!(async { 42 }.block_on(), 42);
assert_eq!(async { 42 }.wait_timeout(Duration::ZERO), Some(42));

There is no separate free-function entry point. Callers that prefer function syntax can use UFCS—asyncband::blocking::FutureExt::block_on(future)—which invokes the same trait method rather than maintaining a second API.

The utility remains separate from the async primitive taxonomy: it bridges synchronous callers to async futures, but it is not itself a synchronization primitive or general-purpose runtime.

There is also no public timeout type. wait_timeout follows the standard action-plus-_timeout naming used by APIs such as recv_timeout and park_timeout and uses Option to keep the generic cancellation boundary explicit and compact.

Timeout semantics

wait_timeout consumes the future and measures the timeout with std::time::Instant:

  • The future is polled before the deadline is checked, so a ready future succeeds even for Duration::ZERO.
  • Some(output) means the future completed; None means the deadline elapsed.
  • Returning None drops the future. For an Asyncband oneshot receiver, that disconnects the receiving endpoint rather than leaving it available for another receive attempt.
  • The deadline cannot interrupt a long-running Future::poll, and the bridge still does not provide runtime timer, I/O, or task drivers.

The parker's timed wait is interruptible through the same waker used by the indefinite path; after either a notification or a spurious wake, the future is polled again before deciding whether the deadline has elapsed.

Parking design

The implementation uses a private three-state notification token (EMPTY, PARKED, and NOTIFIED) adapted from parking 2.2.1. The source attribution and upstream copyright are retained. The atomic token handles notifications delivered before sleep without a lock; Mutex and Condvar are entered only when the calling thread actually needs to sleep.

The parker and waker are cached per thread, following futures-lite. A recursive wait cannot borrow the active cached pair, so it creates a fresh pair and cannot steal the outer call's notification.

The implementation does not call std::thread::park directly. A Thread has one shared notification token, so nested blocking calls or unrelated code parking the same thread can consume or coalesce a wake intended for this bridge. Pollster chose that tradeoff for its lock-free implementation, and its history records both the performance motivation and this correctness limitation:

Adding a private atomic flag around Thread::park would not close the race: a wake can arrive after the flag check and then have its shared thread token consumed by an unrelated park before this bridge parks. An independent waiting token is required for isolation.

cargo tree -p asyncband --no-default-features --features blocking --edges normal contains only asyncband itself.

Performance

The first private implementation allocated a Mutex<bool>/Condvar parker per call and measured 21.42 ns for a ready future and 85.07 ns for a self-waking future. The cached atomic fast path removes that allocation and lock from both cases.

A same-binary local Divan comparison against Pollster 1.0.1 after the trait-only API cleanup (1,000 samples) measured:

Case Private parker Direct Thread::park via Pollster
Ready future 2.188 ns 0.988 ns
Self-wake then pending 4.365 ns 4.568 ns
Cross-thread wake 4.332 us 3.874 us

On this machine, the direct-thread design saves about 1.2 ns for an immediately ready future and about 0.46 us when an actual cross-thread sleep occurs; the private parker is slightly faster in the self-wake case. Those differences do not justify exposing Asyncband to the shared-token deadlocks above. A permanent ready_with_timeout benchmark also records the Instant/deadline overhead of the bounded API (16.77 ns median in the same run).

Tests

  • Cover suffix and UFCS invocation of FutureExt::block_on, ready-before-zero-timeout behavior, and actual timeout expiry.
  • Drive a timed pending future with a deterministic cross-thread wake-up.
  • Verify nested indefinite/timed waits use independent notification tokens.
  • Preload an unrelated standard thread park token, wait on a pending future, and verify the unrelated token is still available afterward.
  • Verify timing out an Asyncband oneshot receiver drops it and returns the unsent value to the sender.
  • Compose the public blocking methods with Asyncband Mutex and oneshot.
  • Benchmark ready, bounded-ready, self-waking, and cross-thread-woken futures.

Branch update

Merged the latest main, including #158, into the existing contributor branch without rewriting its published history.

Validation

  • cargo x check
  • cargo x test
  • cargo +1.86.0 x test
  • cargo +nightly x lint
  • cargo x bench
  • cargo tree -p asyncband --no-default-features --features blocking --edges normal

Provide a minimal single-future blocking executor behind the
block_on Cargo feature, with block_on, block_on_timeout, and
FutureExt methods.
# Conflicts:
#	CHANGELOG.md
#	README.md
#	asyncband/Cargo.toml
#	asyncband/src/lib.rs
#	xtask/src/main.rs
@tisonkun tisonkun changed the title feat: add opt-in block_on executor feat: add opt-in synchronous blocking bridge Aug 19, 2026
Keep only block_on and FutureExt, and give each invocation an isolated parking signal so it cannot consume the current thread's shared park token.
@tisonkun tisonkun changed the title feat: add opt-in synchronous blocking bridge feat: add opt-in synchronous block_on bridge Aug 19, 2026
Inline the private parker so the blocking feature stays dependency-free while preserving independent notification tokens.
@tisonkun tisonkun changed the title feat: add opt-in synchronous block_on bridge feat: add opt-in synchronous future bridge Aug 19, 2026

@tisonkun tisonkun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for starting this contribution. The original revision established the feature-gated, single-future synchronous bridge, including indefinite and timed waits plus the FutureExt calling style. I updated the contributor branch in place and am approving the resulting design.

For clarity, these are the maintainer-side changes relative to the original author revision:

  • Updated the branch to current main, renamed the module/feature from block_on to blocking, and documented it as synchronous interoperability rather than an async primitive.
  • Reduced the public surface from free block_on / block_on_timeout functions, matching extension methods, and a public Timeout type to only FutureExt::block_on and FutureExt::wait_timeout. UFCS (FutureExt::block_on(future)) still provides function syntax without a second entry point. A timeout now returns None and explicitly drops/cancels the consumed future.
  • Replaced the direct std::thread::park token with an isolated three-state parker. The parker/waker pair is cached per thread, recursive waits receive a fresh pair, and unrelated thread parking cannot consume its notifications.
  • Inlined the small required parker state machine instead of adding a normal dependency. Ready and early-notification paths are atomic; Mutex and Condvar are used only when the thread actually sleeps.
  • Moved observable API coverage to integration tests, replaced sleep-based coordination with deterministic wakeups where practical, and added regressions for nested waits, unrelated thread park tokens, timeout expiry, and oneshot cancellation. Public composition and Divan benchmark coverage were added as well.
  • Reworked the README, rustdoc, attribution, and Changelog around the final released API rather than the intermediate commit history.

The feature matrix, stable and Rust 1.86 test suites, lint/rustdoc checks, benchmarks, and Linux/macOS/Windows CI all pass on the final revision.

@tisonkun
tisonkun enabled auto-merge (squash) August 19, 2026 14:32
@tisonkun
tisonkun merged commit 7817a5f into apache:main Aug 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider an opt-in asyncband::block_on module

2 participants