There are many gotchas around cancellation, but IMHO it's not a single problem, there isn't a single cause of them.
There are some unexpected behaviors, but that doesn’t automatically mean that the functionality is incorrect. For example, this is a gotcha:
try_join!(shutdown_a, shutdown_b);
When one of the futures returns error, the other will be cancelled, and may not finish shutting down. This may leave some tasks running in the background that shouldn't be.
I don't blame the user here for using anything wrong. The behavior unexpected, and it's easy to overlook it.
But it's not obvious that this is a wrong behavior, because in another context the same behavior is desirable:
let (file_a, file_b) = try_join!(download_a, download_b);
In this example, function will return with an error if any of the two downloads fails. It would be pretty annoying if try_join waited for the other download to finish completely only to discard it immediately before returning with an error.
When a future from shutdown() is cancelled and doesn't abort the work happening elsewhere, it's a footgun and an undesirable behavior. OTOH tokio::spawn() returns a future that also doesn't abort the work happening elsewhere, but in this context this is a feature, not a bug.
Combining both tokio::spawn(shutdown) cancels the cancellation, and works great! But tokio::spawn isn't magic that fixes cancellation, because tokio::spawn(download) would waste bandwidth and memory collecting cancelled results that can't be used.
Even the same API call, running exactly the same code, can have different desired behavior for cancellation:
request("/api/logout").await;
vs
request("/api/long_poll_notifications").await;
When a program shuts down, you may want to give a logout API call a chance to finish. OTOH a request that waits for new message to arrive could sit there indefinitely, and you'll want it to be cancellable without waiting.
Code like timeout(channel.send(msg)) will irreversibly drop unsent message on cancellation (timeout). Whether this is good or not is also context-dependent — it could be storage_queue.send(precious_data) that you want to always deliver the message, but it could be playback.send(beep_sound), and if the speaker is already too busy playing too many beeps, it's fine to drop the message.
So this is why I don't like framing of futures as "cancel safe" and "not cancel safe", because that has a built-in judgement that one way is better than the other, but it's not universal.
APIs like async Drop or Forget are very useful, and necessary to reliably handle cases that must run to completion, but they're not a complete solution, because there are also plenty of cases where it's intended and perfectly fine to abort futures immediately and drop their state. So we're also missing a way to express the intention, and make sure it composes well (or fails loudly) with combinators like try_join! or select!.