improved performance, concurrent upload, version 0.2.0

This commit is contained in:
nvrl committed 2026-07-24 20:29:15 +02:00
1 parent fcf4fc9fb0
commit 9dd535b374
15 files changed
+1783 -276

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ readme = "../../README.md"
documentation = "https://docs.rs/proton-drive-rs"
[dependencies]
proton-sdk = { path = "../proton-sdk", version = "0.1.12" }
proton-sdk = { path = "../proton-sdk", version = "0.2.0" }
tokio.workspace = true
reqwest.workspace = true
serde.workspace = true
File diff suppressed because it is too large. Load diff
+54 -21
View File
@@ -108,10 +108,31 @@ pub async fn decrypt_link_verified(
link: &LinkDto,
) -> Result<(DecryptedLink, NodeVerification)> {
let (passphrase, name, verification) = decrypt_link_parts(account, parent_key, link).await?;
let node_key = PrivateKey::from_armored(&link.key, &passphrase)?;
// Unlocking the node key runs Proton's deliberately expensive S2K — tens of
// milliseconds of pure CPU, per node. Off the runtime it goes, so a folder
// listing decrypting many nodes at once uses more than one core and never
// stalls the reactor (the download path offloads its block crypto the same
// way, see `revision::decrypt_block_blocking`).
let armored_key = link.key.clone();
let node_key =
blocking(move || Ok(PrivateKey::from_armored(&armored_key, &passphrase)?)).await?;
Ok((DecryptedLink { node_key, name }, verification))
}
/// Run CPU-bound PGP work on the blocking pool, mapping a join failure into a
/// [`ProtonError`].
async fn blocking<T, F>(work: F) -> Result<T>
where
F: FnOnce() -> Result<T> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work)
.await
.map_err(|e| ProtonError::invalid_operation(format!("link decrypt task failed: {e}")))?
}
/// As [`decrypt_link_verified`], but stops short of unlocking the node's own
/// key: it yields only what the parent key can read (the name, and the
/// signature statuses).
@@ -146,28 +167,40 @@ async fn decrypt_link_parts(
AuthorshipClaim::create(account, link.name_signature_email.as_deref()).await
};
// Passphrase: decrypted with the parent key, signed (detached) by the node
// author; verify against the node claim (fallback = parent key).
let passphrase = parent_key.decrypt_armored_message(&link.passphrase)?;
let passphrase_status = match &link.passphrase_signature {
Some(sig) => verify_detached(sig, &passphrase, &node_claim.ring(parent_key)),
None => VerificationStatus::NotSigned,
};
// The rings are resolved here (they may hit the key-resolution endpoint);
// everything after this point is pure PGP work and runs off the runtime.
let node_ring = node_claim.ring(parent_key);
let name_ring = name_claim.ring(parent_key);
let parent_key = parent_key.clone();
let armored_passphrase = link.passphrase.clone();
let passphrase_signature = link.passphrase_signature.clone();
let armored_name = link.name.clone();
// Name: an inline-signed message addressed to the parent key.
let (name_bytes, name_status) =
parent_key.decrypt_armored_verify(&link.name, &name_claim.ring(parent_key))?;
let name = String::from_utf8(name_bytes).map_err(|e| {
ProtonError::invalid_operation(format!("node name is not valid UTF-8: {e}"))
})?;
blocking(move || {
// Passphrase: decrypted with the parent key, signed (detached) by the
// node author; verify against the node claim (fallback = parent key).
let passphrase = parent_key.decrypt_armored_message(&armored_passphrase)?;
let passphrase_status = match &passphrase_signature {
Some(sig) => verify_detached(sig, &passphrase, &node_ring),
None => VerificationStatus::NotSigned,
};
let verification = NodeVerification {
name: name_status,
passphrase: passphrase_status,
content_key: None,
extended_attributes: None,
};
Ok((passphrase, name, verification))
// Name: an inline-signed message addressed to the parent key.
let (name_bytes, name_status) =
parent_key.decrypt_armored_verify(&armored_name, &name_ring)?;
let name = String::from_utf8(name_bytes).map_err(|e| {
ProtonError::invalid_operation(format!("node name is not valid UTF-8: {e}"))
})?;
let verification = NodeVerification {
name: name_status,
passphrase: passphrase_status,
content_key: None,
extended_attributes: None,
};
Ok((passphrase, name, verification))
})
.await
}
/// Decrypt a file's content key and verify its `ContentKeyPacketSignature`.
+20 -4
View File
@@ -174,7 +174,7 @@ impl LinkDetailsDto {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
pub struct LinkDto {
#[serde(rename = "LinkID")]
pub id: LinkId,
@@ -837,7 +837,7 @@ pub struct BlockUploadPreparationRequest {
pub thumbnails: Vec<ThumbnailCreationRequest>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct BlockCreationRequest {
#[serde(rename = "Index")]
pub index: i32,
@@ -854,7 +854,7 @@ pub struct BlockCreationRequest {
pub verifier: BlockVerifier,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct BlockVerifier {
/// Base64 verification token (`code XOR ciphertext_prefix`).
#[serde(rename = "Token")]
@@ -863,7 +863,7 @@ pub struct BlockVerifier {
/// Thumbnail creation entry in a block-upload preparation request. Mirrors C#
/// `ThumbnailCreationRequest` (`Size`, `Type`, base64 ciphertext `Hash`).
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct ThumbnailCreationRequest {
#[serde(rename = "Size")]
pub size: i32,
@@ -888,6 +888,16 @@ pub struct BlockUploadTarget {
pub bare_url: String,
#[serde(rename = "Token")]
pub token: String,
/// Index of the content block this target is for, echoed from the request.
/// `Option` because it is absent on thumbnail targets and older responses;
/// the uploader then falls back to request order. TS reads the same field
/// (`UploadLinks[].Index` in `apiService.requestBlockUpload`).
#[serde(rename = "Index", default)]
pub index: Option<i32>,
/// Type of the thumbnail this target is for, echoed from the request
/// (TS `ThumbnailLinks[].ThumbnailType`). Absent on content-block targets.
#[serde(rename = "ThumbnailType", default)]
pub thumbnail_type: Option<i32>,
}
/// `PUT v2/volumes/{vid}/files/{lid}/revisions/{rid}` — seal the revision.
@@ -1076,6 +1086,12 @@ pub struct SharedByMeLinkDto {
pub share_id: ShareId,
#[serde(rename = "LinkID")]
pub link_id: LinkId,
/// The share the link is *reached through* — the context in which it lives,
/// which differs from `share_id` for an item inside a shared folder. C#
/// `SharedByMeLinkDto.ContextShareId`; optional here because the field is a
/// recent upstream addition and an older backend simply omits it.
#[serde(rename = "ContextShareID", default)]
pub context_share_id: Option<ShareId>,
}
/// The kind of item a share points at. C# `ShareTargetType`.
+1
View File
@@ -44,6 +44,7 @@ mod photos;
mod public_link;
mod revision;
mod sharing;
mod single_flight;
pub use cache::{CachedNodeInfo, DriveEntityCache};
pub use client::ProtonDriveClient;
+9
View File
@@ -112,6 +112,15 @@ impl ProtonPhotosClient {
self.drive.enumerate_photos_timeline().await
}
/// The photos I have shared with others, as [`NodeUid`]s — the photos-volume
/// counterpart of `ProtonDriveClient::enumerate_shared_by_me_node_uids`.
/// C# `ProtonPhotosClient.EnumerateSharedNodeUidsAsync`. Empty when the
/// account has no photos volume. Materialize with
/// [`enumerate_nodes`](Self::enumerate_nodes).
pub async fn enumerate_shared_node_uids(&self) -> Result<Vec<NodeUid>> {
self.drive.enumerate_photos_shared_by_me_node_uids().await
}
/// Download and decrypt a photo's active revision, returning its plaintext.
/// C# `PhotosFileDownloader`.
pub async fn download_photo(&self, uid: &NodeUid) -> Result<Vec<u8>> {
+158
View File
@@ -0,0 +1,158 @@
//! Collapse concurrent identical loads into one request.
//!
//! Mirrors the intent of the TypeScript SDK's `internal/nodes/debouncer.ts` and
//! `internal/batchLoading.ts`: several callers asking for the same node — or the
//! same folder's key — at the same moment should cost one round-trip and one
//! decrypt, not one each. The batching half of those modules has no counterpart
//! here; this crate's loads are already issued in batches.
use std::collections::HashMap;
use std::future::Future;
use std::hash::Hash;
use std::sync::Arc;
use futures::future::{BoxFuture, FutureExt, Shared};
use proton_sdk::error::{ProtonError, Result};
use tokio::sync::Mutex;
type SharedLoad<V> = Shared<BoxFuture<'static, std::result::Result<V, Arc<ProtonError>>>>;
/// A set of in-flight loads keyed by what they load.
pub(crate) struct SingleFlight<K, V> {
inflight: Mutex<HashMap<K, SharedLoad<V>>>,
}
impl<K, V> Default for SingleFlight<K, V> {
fn default() -> Self {
Self {
inflight: Mutex::new(HashMap::new()),
}
}
}
impl<K, V> SingleFlight<K, V>
where
K: Clone + Eq + Hash,
V: Clone + Send + 'static,
{
/// Run `load` for `key`, or join the one already running for it.
///
/// `load` must be self-contained (`'static`) because the caller that starts
/// it may go away before it finishes, leaving another to drive it.
///
/// **Errors are shared, and sharing costs type fidelity.** [`ProtonError`]
/// is not `Clone` (its `Transport` variant wraps a `reqwest::Error`), so a
/// failed load hands every waiter — the one that started it included — a
/// rebuilt error: an API failure keeps its full [`ProtonApiError`], and
/// everything else degrades to
/// [`ProtonError::InvalidOperation`](proton_sdk::error::ProtonError::InvalidOperation)
/// carrying the original message. Callers that match on `ResponseCode` are
/// therefore unaffected; callers that match on the *variant* of a transport
/// failure would be, and none do.
pub(crate) async fn run<F>(&self, key: K, load: F) -> Result<V>
where
F: Future<Output = Result<V>> + Send + 'static,
{
let shared = {
let mut inflight = self.inflight.lock().await;
match inflight.get(&key) {
Some(existing) => existing.clone(),
None => {
let load = load
.map(|outcome| outcome.map_err(Arc::new))
.boxed()
.shared();
inflight.insert(key.clone(), load.clone());
load
}
}
};
let outcome = shared.await;
// Clear the slot so the *next* caller gets a fresh load rather than this
// one's result. A racing arrival that already cloned the handle still
// gets this result, which is the point.
self.inflight.lock().await.remove(&key);
outcome.map_err(|error| match error.as_ref() {
ProtonError::Api(e) => ProtonError::Api(e.clone()),
other => ProtonError::invalid_operation(other.to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::api::ResponseCode;
use proton_sdk::error::ProtonApiError;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Notify;
#[tokio::test]
async fn concurrent_loads_of_the_same_key_run_once() {
let flight: SingleFlight<&'static str, usize> = SingleFlight::default();
let runs = Arc::new(AtomicUsize::new(0));
// The gate holds the load open until every waiter has registered — the
// case single-flighting exists for. A `Notify` rather than a one-shot so
// that a *failure* to deduplicate shows up as a wrong count, not a hang.
let gate = Arc::new(Notify::new());
let waiters = (0..8).map(|_| {
let runs = runs.clone();
let gate = gate.clone();
flight.run("node", async move {
gate.notified().await;
Ok(runs.fetch_add(1, Ordering::SeqCst))
})
});
let (outcomes, ()) = tokio::join!(futures::future::join_all(waiters), async {
for _ in 0..4 {
tokio::task::yield_now().await;
}
gate.notify_waiters();
});
for outcome in outcomes {
assert_eq!(outcome.expect("load"), 0);
}
assert_eq!(runs.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_later_load_is_not_served_the_previous_result() {
let flight: SingleFlight<&'static str, usize> = SingleFlight::default();
assert_eq!(flight.run("node", async { Ok(1) }).await.expect("first"), 1);
assert_eq!(
flight.run("node", async { Ok(2) }).await.expect("second"),
2
);
}
#[tokio::test]
async fn a_shared_api_failure_keeps_its_response_code() {
let flight: SingleFlight<&'static str, usize> = SingleFlight::default();
let error = flight
.run("node", async {
Err(ProtonError::Api(ProtonApiError {
code: ResponseCode::DoesNotExist,
http_status: 422,
message: "gone".into(),
details: None,
}))
})
.await
.expect_err("load fails");
match error {
ProtonError::Api(e) => {
assert!(matches!(e.code, ResponseCode::DoesNotExist));
assert_eq!(e.http_status, 422);
assert_eq!(e.message, "gone");
}
other => panic!("expected an api error, got {other}"),
}
}
}
+69 -2
View File
@@ -11,7 +11,7 @@ mod common;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::{Node, NodeKind};
use proton_sdk::ids::NodeUid;
use proton_sdk::ids::{LinkId, NodeUid};
/// Trash then permanently delete the given nodes; best-effort, logs on failure.
async fn cleanup(client: &proton_drive_rs::ProtonDriveClient, uids: &[NodeUid]) {
@@ -224,11 +224,18 @@ async fn move_nodes_batch() {
.await
.expect("create c2");
client
let outcomes = client
.move_nodes(&[c1.clone(), c2.clone()], &dst)
.await
.expect("move_nodes");
assert_eq!(outcomes.len(), 2, "one outcome per requested node");
for (uid, outcome) in &outcomes {
outcome
.as_ref()
.unwrap_or_else(|e| panic!("move of {uid} must succeed: {e}"));
}
assert_eq!(
get(client, &c1, "c1 after move").await.parent_uid.as_ref(),
Some(&dst),
@@ -243,6 +250,66 @@ async fn move_nodes_batch() {
cleanup(client, &[c1, c2, dst]).await;
}
/// One bad node must not sink the batch: `move_nodes` reports per-node outcomes
/// (C# `MoveNodesAsync`'s result map), so a bogus link id fails alone while its
/// neighbour still moves.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn move_nodes_partial_failure() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let suffix = common::unique_suffix();
let dst = client
.create_folder(&root.uid, &format!("partial-dst-{suffix}"), None)
.await
.expect("create dst");
let good = client
.create_folder(&root.uid, &format!("partial-good-{suffix}"), None)
.await
.expect("create good");
let bogus = NodeUid::new(
dst.volume_id.clone(),
LinkId::new(format!("nonexistent-link-{suffix}")),
);
let outcomes = client
.move_nodes(&[good.clone(), bogus.clone()], &dst)
.await
.expect("move_nodes must not fail as a whole");
assert_eq!(outcomes.len(), 2, "one outcome per requested node");
assert_eq!(outcomes[0].0, good, "outcomes keep the input order");
outcomes[0]
.1
.as_ref()
.expect("the real node must still move");
assert_eq!(outcomes[1].0, bogus);
let e = outcomes[1]
.1
.as_ref()
.expect_err("the bogus node must report its own failure");
eprintln!("[info] bogus node outcome: {e}");
assert_eq!(
get(client, &good, "good after move")
.await
.parent_uid
.as_ref(),
Some(&dst),
"the real node must be under dst"
);
cleanup(client, &[good, dst]).await;
}
// ---------------------------------------------------------------------------
// Signature verification
// ---------------------------------------------------------------------------
+32 -1
View File
@@ -18,7 +18,9 @@
mod common;
use proton_drive_rs::{MemberRole, ProtonDriveClient, ProtonDrivePublicLinkClient};
use proton_drive_rs::{
MemberRole, ProtonDriveClient, ProtonDrivePublicLinkClient, ProtonPhotosClient,
};
use proton_sdk::ids::NodeUid;
/// Trash then permanently delete the given node; best-effort, logs on failure.
@@ -251,6 +253,35 @@ async fn shared_with_me_and_incoming_invitations_read() {
eprintln!("[info] incoming invitations count: {}", incoming.len());
}
/// The shared-by-me read surface must resolve on both volumes: the main volume
/// (`ProtonDriveClient`) and the photos volume (`ProtonPhotosClient`, C#
/// `EnumerateSharedNodeUidsAsync`). Empty is a pass — an account with nothing
/// shared, or with no photos volume at all, must not error.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn shared_by_me_read() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let shared = client
.enumerate_shared_by_me_node_uids()
.await
.expect("enumerate_shared_by_me_node_uids must not error");
eprintln!("[info] shared-by-me count (main volume): {}", shared.len());
let photos = ProtonPhotosClient::from_drive_client(client.clone());
let shared_photos = photos
.enumerate_shared_node_uids()
.await
.expect("photos enumerate_shared_node_uids must not error");
eprintln!(
"[info] shared-by-me count (photos volume): {}",
shared_photos.len()
);
}
// ---------------------------------------------------------------------------
// Public link — consuming a link as an anonymous visitor
// ---------------------------------------------------------------------------
@@ -594,3 +594,135 @@ async fn revision_history_lists_reads_restores_and_deletes() {
cleanup(client, &[file]).await;
}
/// Multi-block upload carrying thumbnails: their tokens now ride the first
/// block-token request instead of being prepared and stored one request each,
/// so the manifest layout (thumbnail digests first, in type order) has to
/// survive a pipeline that stores blocks out of order.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn upload_download_multiblock_with_thumbnails_roundtrip() {
use proton_drive_rs::{Thumbnail, ThumbnailType};
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
// 10 MiB → 3 content blocks, plus both thumbnail types.
let size = 10 * 1024 * 1024;
let mut payload = vec![0u8; size];
for (i, b) in payload.iter_mut().enumerate() {
*b = (i as u32).wrapping_mul(2_654_435_761) as u8;
}
let small = b"small-thumbnail-bytes".to_vec();
let preview = b"preview-thumbnail-bytes-which-are-longer".to_vec();
let name = format!("rt-thumbs-{}.bin", common::unique_suffix());
let uid = client
.upload_file_from(
&root.uid,
&name,
"application/octet-stream",
std::io::Cursor::new(payload.clone()),
size as i64,
vec![
// Deliberately out of type order: the uploader sorts.
Thumbnail::new(ThumbnailType::Preview, preview.clone()).expect("preview thumbnail"),
Thumbnail::new(ThumbnailType::Thumbnail, small.clone()).expect("small thumbnail"),
],
None, // last_modification_time
false, // aead
)
.await
.expect("upload_file_from with thumbnails");
assert_eq!(
client.download_file(&uid).await.expect("download_file"),
payload,
"content must round-trip alongside thumbnails"
);
assert_eq!(
client
.download_thumbnail(&uid, ThumbnailType::Thumbnail)
.await
.expect("download small thumbnail"),
Some(small),
);
assert_eq!(
client
.download_thumbnail(&uid, ThumbnailType::Preview)
.await
.expect("download preview thumbnail"),
Some(preview),
);
cleanup(client, &[uid]).await;
}
/// Upload throughput, pipelined vs. serial — the measurement behind PERF_PLAN's
/// upload item, and the same methodology its download counterpart used.
///
/// `with_max_inflight_blocks(1)` reproduces the pre-pipeline behavior exactly:
/// one block is read, encrypted and stored before the next one starts. The
/// default client keeps several blocks in flight. Both figures are printed
/// rather than asserted on — this is a benchmark, and a shared link's capacity
/// is not something a test can pin down.
#[tokio::test]
#[ignore = "live: benchmark, needs test-account credentials"]
async fn upload_throughput_pipelined_vs_serial() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
// 40 MiB → 10 blocks, matching the download measurement's payload.
let size = 40 * 1024 * 1024;
let mut payload = vec![0u8; size];
for (i, b) in payload.iter_mut().enumerate() {
*b = (i as u32).wrapping_mul(2_654_435_761) as u8;
}
let serial = live.client.clone().with_max_inflight_blocks(1);
let mut uploaded = Vec::new();
for round in 1..=3 {
for (label, client) in [("serial", &serial), ("pipelined", client)] {
let name = format!("bench-{label}-{round}-{}.bin", common::unique_suffix());
let started = std::time::Instant::now();
let uid = client
.upload_file_from(
&root.uid,
&name,
"application/octet-stream",
std::io::Cursor::new(payload.clone()),
size as i64,
Vec::new(),
None,
false,
)
.await
.expect("upload_file_from");
let elapsed = started.elapsed();
eprintln!(
"[upload {label} round {round}] {:.1}s, {:.2} MiB/s",
elapsed.as_secs_f64(),
(size as f64 / (1024.0 * 1024.0)) / elapsed.as_secs_f64()
);
uploaded.push(uid);
}
}
cleanup(client, &uploaded).await;
}
+71 -8
View File
@@ -1,12 +1,12 @@
//! Compile-time guard: the download futures must survive `tokio::spawn`.
//! Compile-time guard: the transfer futures must survive `tokio::spawn`.
//!
//! `download_file`/`download_file_to` and `RevisionReader::read_at` internally
//! drive a `futures::stream::buffered` pipeline. If any stage of that pipeline
//! borrows from a closure argument, the resulting future picks up a
//! higher-ranked lifetime and callers get "implementation of `FnOnce` is not
//! general enough" the moment they `tokio::spawn` it. That error surfaces only
//! *downstream* — this crate builds fine either way — so it went unnoticed until
//! a consumer broke.
//! `download_file`/`download_file_to`, `RevisionReader::read_at`, the upload
//! path and `enumerate_nodes` all internally drive a `futures::stream::buffered`
//! pipeline. If any stage of that pipeline borrows from a closure argument, the
//! resulting future picks up a higher-ranked lifetime and callers get
//! "implementation of `FnOnce` is not general enough" the moment they
//! `tokio::spawn` it. That error surfaces only *downstream* — this crate builds
//! fine either way — so it went unnoticed until a consumer broke.
//!
//! These never run; they only have to compile.
@@ -46,6 +46,69 @@ fn download_range_is_spawnable(
tokio::spawn(async move { client.download_range(&uid, 0, 4096).await })
}
/// `upload_file` inside a spawned task: the whole encrypt/store pipeline, from
/// an in-memory plaintext.
fn upload_file_is_spawnable(
client: ProtonDriveClient,
parent: NodeUid,
) -> JoinHandle<proton_sdk::error::Result<NodeUid>> {
tokio::spawn(async move {
client
.upload_file(
&parent,
"spawned.bin",
"application/octet-stream",
&[0u8; 8],
)
.await
})
}
/// `upload_file_from` inside a spawned task, streaming from an owned reader —
/// the shape the FUSE daemon's drain uses.
fn upload_file_from_is_spawnable(
client: ProtonDriveClient,
parent: NodeUid,
) -> JoinHandle<proton_sdk::error::Result<NodeUid>> {
tokio::spawn(async move {
let plaintext: Vec<u8> = Vec::new();
client
.upload_file_from(
&parent,
"spawned.bin",
"application/octet-stream",
std::io::Cursor::new(plaintext),
0,
Vec::new(),
None,
false,
)
.await
})
}
/// `upload_new_revision_from` inside a spawned task.
fn upload_new_revision_from_is_spawnable(
client: ProtonDriveClient,
uid: NodeUid,
) -> JoinHandle<proton_sdk::error::Result<()>> {
tokio::spawn(async move {
let plaintext: Vec<u8> = Vec::new();
client
.upload_new_revision_from(&uid, std::io::Cursor::new(plaintext), 0, Vec::new(), None)
.await
})
}
/// `enumerate_nodes` inside a spawned task: its per-node builds run through the
/// same `buffered` machinery.
fn enumerate_nodes_is_spawnable(
client: ProtonDriveClient,
uids: Vec<NodeUid>,
) -> JoinHandle<proton_sdk::error::Result<Vec<proton_drive_rs::Node>>> {
tokio::spawn(async move { client.enumerate_nodes(&uids).await })
}
/// `open_revision` + a shared reader's `read_at` inside a spawned task — the
/// shape an on-demand mount uses to fetch several blocks concurrently.
fn reader_read_at_is_spawnable(