Skip to content

RFC: document a strict Future contract that forbids snoozing - #4019

Open
oconnor663 wants to merge 3 commits into
rust-lang:mainfrom
oconnor663:future_contract_squashed
Open

oconnor663 wants to merge 3 commits into
rust-lang:mainfrom
oconnor663:future_contract_squashed

Conversation

@oconnor663

@oconnor663 oconnor663 commented Oct 19, 2026 •

Copy link
Copy Markdown

Require a future's owner to poll it when it requests a wakeup, to avoid snoozing bugs like "Futurelock". This RFC is a docs-only change, but this will have far-reaching consequences for a lot of common async patterns.

RFC #4020 is being submitted at the same time. It adds poll_progress to AsyncIterator, in part to satisfy the strict Future contract introduced in this RFC.

Important

Since RFCs involve many conversations at once that can be difficult to follow, please use review comment threads on the text changes instead of direct comments on the RFC.

If you don't have a particular section of the RFC to comment on, you can click on the "Comment on this file" button on the top-right corner of the diff, to the right of the "Viewed" checkbox. This will create a separate thread even if others have commented on the file too.

Rendered

Require a future's owner to poll it when it requests a wakeup, to avoid
deadlock bugs like ["Futurelock"]. This RFC is a docs-only change, but
this will have far-reaching consequences for a lot of common async
patterns.

RFC TODO is being submitted at the same time. It adds `poll_progress` to
`AsyncIterator`, in part to satisfy the strict `Future` contract
introduced in this RFC.

["Futurelock"]: https://rfd.shared.oxide.computer/rfd/0609

@Darksonn Darksonn 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.

I haven't gotten into the details of the RFC yet, but my general take as Tokio maintainer is that this RFC (i.e. forbidding snoozing) is the correct solution to the FutureLock problem.

View changes since this review


[cancelling_async_rust]: https://sunshowers.io/posts/cancelling-async-rust/

## Rationale and alternatives

@ds84182 ds84182 Oct 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Locking primitives could drop their reservations if they are not polled after wakeup. They haven't emitted Poll::Ready to return the lock guard, so the lock is preemptable.

In practice this is impossible without some new "WakeToken" API, where the token must be delivered to the Future that woke up. Otherwise, dropping the token indicates that the future wasn't polled. Mirroring wakers, there could be a "RawWakeTokenVtable" with a consumed and canceled callback, etc.

However I think it is far too late to have something like this but I'd love for this to be proven wrong :)

View changes since the review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

They haven't emitted Poll::Ready to return the lock guard, so the lock is preemptable.

If I understand you correctly, I think that's true in the concurrent streams example deadlocks, but not in the others. In the others, the snoozed future holds an actual lock guard, not just a spot in the queue. Footnote 11 also has a playground example of a concurrent streams deadlock with an unfair mutex, where there is no queue, to emphasize that lock fairness isn't essential to the problem.

@RalfJung RalfJung 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.

This makes a lot of sense to me! It seems like the futures ecosystem has a liveness problem. Rust is great for reasoning about safety properties, but we have a bit of a blind spot when it comes to liveness. (C++ calls this "forward progress" and they think about it a lot. I do not agree with all their takes on it, especially with how they sometimes needlessly conflate it with UB, but they have clearly recognized the importance of this topic.)

The first step in fixing that is to clearly document the intended contract: who is responsible to ensure which kind of progress? We cannot start fixing bugs if we cannot even pinpoint which component is buggy. I do not have the expertise to say what would make a good liveness contract for Future, but the proposal sounds plausible and it is not clear what the alternative even could be. It'll be a long and painful transition to get every component to adhere to the contract, but fixing the liveness problem has to start somewhere.

We should however also make it clear that failing to uphold the liveness requirement can at worst lead to deadlocks. It is not UB to fail to call poll again. The system must remain safe even if liveness is broken.

View changes since this review

Comment on lines +309 to +326
What these cases have in common, though, is that barring program exit or the
power going out, future progress is guaranteed. There's no legitimate way for
user code to stop the runtime from working through its task list or freeze a
private worker thread.[^illegitimate] The deadlocks above are different: a
future is suspended across some arbitrary bit of user code that isn't
guaranteed to ever finish.

[^illegitimate]: _Illegitimate_ ways to interfere with the runtime include
synchronous blocking in `poll`, calling unsafe functions like
[`pthread_cancel`], or just corrupting memory. That said, the [userspace
executors section][userspace_executors] does include a corner case where
safe and reasonable-looking user code can pause a whole single-threaded
`Runtime`. That might be worth preventing somehow, or it might be
considered low-level enough that documenting it is sufficient.

A fully formal definition of "promptly" will probably end up with somewhat
unsatisfying wording like "a finite period of time". Consider this excerpt from
[the C++ atomic memory model][finite]:

@RalfJung RalfJung Oct 21, 2026 •

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.

This is not unsatisfying, this is reflecting the way we can formally reason about liveness properties in a formal framework like LTL.

Saying "promptly" seems to imply some sort of quantitative property, like "within 1 second" or so. From what I understood about the bugs this is intended to fix, that is entirely unnecessary. The only guarantee we truly need is that it will eventually be called. ("eventually" is a formal operator in LTL and a standard ingredient in liveness properties.) How fast it is being called is a quality-of-implementation concern. But any system that arranges for the future to eventually be polled again will eventually make progress, and that is the key formal property that one typically reasons about in this context -- and that is supported by tools (e.g. TLA+), theory, and tons of research.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There are definitely bugs that sit between the two.

Something like:

while let Some(item) = stream.next().await {
    process(item).timeout_ms(1000).await;
}

This doesn't deadlock because the loop body will never await longer than a second, but it's clearly an unintended consequence that the stream futures can sit around "un-polled" for that length of time.

It does seem a lot harder to set up a system when you can prove that this kind of bug does not exist though. Maybe you could treat each Future as a black box and then attempt to prove that progress will be made "eventually" without relying on details inside the future (such as it completing in 1000ms in this case).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It does seem a lot harder to set up a system when you can prove that this kind of bug does not exist though.

Yeah this is more like how I'm thinking about it. This particular loop can't deadlock, but that's sensitive to what we put in the body, so I think it's fair to say that .next() as an individual component isn't living up to the intended contract. We can mix and match components like Timeout and Map and Then without ever causing a deadlock, but components like stream::Next and future::Shared are "deadlock prone". If we wanted to split hairs, we could say that they're not fundamentally broken and that they just need to document extensive requirements for their callers to use them correctly. (As Ralf said, none of this is UB after all.) But realistically, no one wants to ship a .next() method with a bunch of global preconditions and scary warnings attached to it and then blame users for inevitably using it wrong.

The examples I have in my head of someone "exploiting" the finite-period-of-time rule would be something like a runtime that deliberately sleeps for a couple seconds before polling tasks, or a future/stream adapter that spawns a thread to poll its children after a sleep. Obviously(?) we don't want runtimes or components to do silly things like that. But 1) it's not clear to me that there's a formal difference between doing unnecessary synchronous sleeps and e.g. a regular task queue that's just overloaded and bogged down. And 2) doing silly extra sleeps doesn't create non-local conflicts where we need a higher authority to tell us who's wrong; it's just a regular bug.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I could definitely see a case where a runtime might decide to do short sleeps between polls in order to save power say.

@oconnor663 oconnor663 Oct 22, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

"eventually" is a formal operator in LTL and a standard ingredient in liveness properties

We could call this part of the contract the "poll eventually rule" instead of the "poll promptly rule"? It might be a tradeoff between clearly invoking the formal theory, vs describing what implementers should actually do 99% of the time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think that's the right contract. MaybeNever is allowed to never call the future again

As @oconnor663 said, the implementation always polls the inner future to completion, it just makes whether that completion reaches the caller non-deterministic, so it's definitely possible to satisfy the contract. (Polling the future != the future returning Ready)

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.

Okay, I see now. You basically want to say that I cannot rely on any other future to ever be finished when arguing that I will invoke poll again.

I don't like that. Reasoning based on "that other code I wrote terminates hence this loop will terminate" is pretty standard liveness reasoning. I don't see a good reason to forbid it. I think this just makes the contract harder to understand and harder to reason about without sufficient benefit.

It's also oddly special-cased to wrap all futures in MaybeNever. If for some reason I implement timeout_ms with my own future-like type that's based on my own Poll-like type (and a custom executor that supports them) then obviously it wouldn't get wrapped and hence the new clause would change nothing. I don't see a reason why one would do that, but I also don't see a reason why it should make any difference for whether a given future satisfies the liveness requirements.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Okay, I see now. You basically want to say that I cannot rely on any other future to ever be finished when arguing that I will invoke poll again.

Yes. I think this aligns well with the goal of allowing futures to make independent progress / preventing deadlocks or unwanted pauses.

Reasoning based on "that other code I wrote terminates hence this loop will terminate" is pretty standard liveness reasoning.

The opposite is also pretty standard to reason about with concepts like wait-freedom, where we must be able to prove that any thread can make progress without any other thread progressing. I think there are some pretty strong parallels here actually.

I don't see a good reason to forbid it. I think this just makes the contract harder to understand and harder to reason about without sufficient benefit.

You may be right, but I don't think it's clear cut. This contract is still relatively easy to understand and excludes some very common code bug classes with futures that the simpler contract does not.

It's also oddly special-cased to wrap all futures in MaybeNever.

We are talking about the contract of the Future trait, so it makes sense that it would be special-cased to implementations of that trait, no? Obviously if you made your own future-like trait you could make up whatever contract you want for that trait? That's true even for the simpler contract: someone could make a SnoozableFuture trait where it's allowed to never be polled again.

@RalfJung RalfJung Oct 23, 2026 •

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.

The opposite is also pretty standard to reason about with concepts like wait-freedom, where we must be able to prove that any thread can make progress without any other thread progressing. I think there are some pretty strong parallels here actually.

The sleep happens in our "thread" (future) though, so we're not relying on other threads.

This contract is still relatively easy to understand

I beg to differ. ;)

We are talking about the contract of the Future trait, so it makes sense that it would be special-cased to implementations of that trait, no?

No. This is about "sources of waiting that are allowed to occur until poll gets called". I don't see why "waiting for a Future to become Ready" should be special there. It's not about the Future the contract is attached to after all, it's about all the other futures elsewhere in the program. There are many sources of waiting and there's no reason to special-case futures.

Like, for instance this is still allowed under your contract:

while let Some(item) = stream.next().await {
    std::thread::sleep_ms(100);
}

That seems kind of silly, to allow this but disallow the equivalent that's entirely built on Futures. (That program is obviously a bad idea, but that's not the point. It upholds unbounded liveness, and I explained above why unbounded liveness is a very desirable property even though there are trivial examples like this where it seems to be utterly useless.)

That's also why the rest of this paragraph is missing the point; it's not about the future the contract is attached to. Obviously MyFuture can decide not to have a contract, but that's entirely separate from the question of whether Future is allowed to wait for MyFuture (if we can prove that a particular MyFuture will definitely eventually be ready). You are essentially saying that Future must eventually be polled again and as caller I can rely on everything I know about the program except that calls to Future:poll are altered in a specific way (non-deterministically replacing Ready by Pending). But no other function call is altered even though obviously any other function could be doing literally the same thing! That has no parallel with anything I've ever seen, causes a lot of complications and problems for everyone who wants to reason about this systematically and formally, and doesn't actually solve any problem.

The more I think about this, the stronger I am opposed. We shouldn't unilaterally alter the user's program, not even for the purpose of hypothetical executions that we only use to define a liveness contract. Let the user reason about the code they actually wrote.

@RalfJung RalfJung Oct 23, 2026 •

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.

The analogy with wait-freedom also does not hold up.

Wait-freedom is the strongest non-blocking progress guarantee for concurrent data structures,
ensuring that every operation completes in a finite number of steps regardless of interference from
other threads.

(source)

This is true for the original example as well. Your description as "we must be able to prove that any thread can make progress without any other thread progressing" is an inaccurate summary. It's about interference from other thread preventing progress on the current thread, e.g. by making CAS loops fail every single time. No amount of interference can prevent the sleep from completing, which means that the loop that sleeps is indeed wait-free.

@oconnor663

Copy link
Copy Markdown
Author

We should however also make it clear that failing to uphold the liveness requirement can at worst lead to deadlocks. It is not UB to fail to call poll again. The system must remain safe even if liveness is broken.

Agreed. In the poll_progress RFC I have the wording "it might panic or otherwise misbehave (within the bounds of safe code)", and we could add more clarifications like that to both.

@RalfJung

Copy link
Copy Markdown
Member

Agreed. In the poll_progress RFC I have the wording "it might panic or otherwise misbehave (within the bounds of safe code)", and we could add more clarifications like that to both.

I am not sure we should allow arbitrary safe misbehavior. Is there a reason to allow it?
I can see a reason to allow panics (from deadlock detection). But otherwise I don't think it is a good idea to turn a liveness issue into a safety issue. (Even "safe misbehavior" is a safety issue in the formal sense of safety, i.e., violating an invariant of the program that would otherwise always hold true.)

@oconnor663

Copy link
Copy Markdown
Author

Ah sorry, I meant to emphasize the "safe code" part. It was confusing of me to quote the part about panicking when what we're talking about here is the consequences of not calling poll when you're supposed to.

That said, the current poll docs do have this to say about calling poll after it's previously returned Ready, which sounds pretty similar to "arbitrary safe misbehavior":

Once a future has completed (returned Ready from poll), calling its poll method again may panic, block forever, or cause other kinds of problems; the Future trait places no requirements on the effects of such a call. However, as the poll method is not marked unsafe, Rust’s usual rules apply: calls must never cause undefined behavior (memory corruption, incorrect use of unsafe functions, or the like), regardless of the future’s state.

@RalfJung

RalfJung commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Yes, calling poll again after Ready is breaking a safety property. Arbitrary safe misbehavior seems entirely reasonable there.

But failing to call poll again after Pending is breaking a liveness property, and I don't think arbitrary safe misbehavior is an appropriate response to a liveness bug.

This branch has not been deployed

No deployments
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.

5 participants