Skip to content

Commit c3a514e

Browse files
ehsan6shaclaude
andcommitted
fula-client 0.6.14: wasm/web cancellable chunked upload (mirror native cancel)
The web (non-resumable) chunked-upload path had no cancel — only the native resumable path could abort an in-flight upload (an Arc<AtomicBool> checked between chunk PUTs). This adds the SAME cooperative cancel to the wasm-capable non-resumable path so the web Sync Queue can stop an in-progress large upload. - `put_object_chunked_internal` + the shared `put_object_flat_deferred_locked` take an optional `cancel: Option<Arc<AtomicBool>>` (the 3 existing callers pass None — unchanged). The per-chunk `buffer_unordered` closure checks it before the chunk PUT: a chunk not yet started short-circuits with `ClientError::Cancelled`; chunks already in flight (<=16) finish. A second check before the index PUT covers cancel-after-all-chunks. The existing post-loop compensating-delete removes the uploaded (now-unreferenced) chunks — the same cleanup as the failure path. Mirrors the native resumable cancel. - New `pub put_object_flat_with_progress_cancellable(... progress, cancel)`. - FRB binding `put_flat_with_progress_cancellable(&ProgressHandle, &CancelHandle)` shares the CancelHandle's `Arc<AtomicBool>` with the upload, so triggering the Dart-held handle sets the flag the upload polls. NON-resumable: a cancelled upload restarts from scratch — wasm RESUMABLE support is the next change (0.6.15). Compiles on native + wasm32 + flutter; a unit test asserts a pre-set cancel flag aborts with `Cancelled`; the progress and native-resumable regression tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9ab96f3 commit c3a514e

5 files changed

Lines changed: 150 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ name = "encrypted_upload_test"
7979
path = "examples/encrypted_upload_test.rs"
8080

8181
[workspace.package]
82-
version = "0.6.13"
82+
version = "0.6.14"
8383
edition = "2021"
8484
license = "MIT OR Apache-2.0"
8585
repository = "https://github.com/functionland/fula-api"

crates/fula-client/src/encryption.rs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6856,7 +6856,7 @@ impl EncryptedClient {
68566856
let lock = self.bucket_write_lock(bucket);
68576857
let _guard = lock.lock().await;
68586858
let result = self
6859-
.put_object_flat_deferred_locked(bucket, key, data, content_type, None)
6859+
.put_object_flat_deferred_locked(bucket, key, data, content_type, None, None)
68606860
.await?;
68616861
self.flush_forest_locked(bucket).await?;
68626862
Ok(result)
@@ -6881,7 +6881,40 @@ impl EncryptedClient {
68816881
let lock = self.bucket_write_lock(bucket);
68826882
let _guard = lock.lock().await;
68836883
let result = self
6884-
.put_object_flat_deferred_locked(bucket, key, data, content_type, Some(progress))
6884+
.put_object_flat_deferred_locked(bucket, key, data, content_type, Some(progress), None)
6885+
.await?;
6886+
self.flush_forest_locked(bucket).await?;
6887+
Ok(result)
6888+
}
6889+
6890+
/// [`put_object_flat_with_progress`] with cooperative cancellation, for
6891+
/// web (and native) callers that need to abort an in-flight large upload.
6892+
/// `cancel` is checked between chunk PUTs: chunks already in flight
6893+
/// (≤ MAX_CONCURRENT_CHUNK_UPLOADS) finish, later chunks short-circuit, the
6894+
/// uploaded chunks are deleted, and the call returns
6895+
/// `ClientError::Cancelled`. NON-resumable: a cancelled upload restarts from
6896+
/// scratch (wasm resumable support lands separately). Mirrors the native
6897+
/// resumable path's `Arc<AtomicBool>` cancel semantics.
6898+
pub async fn put_object_flat_with_progress_cancellable(
6899+
&self,
6900+
bucket: &str,
6901+
key: &str,
6902+
data: impl Into<Bytes>,
6903+
content_type: Option<&str>,
6904+
progress: Arc<dyn Fn(u64, u64) + Send + Sync>,
6905+
cancel: Arc<std::sync::atomic::AtomicBool>,
6906+
) -> Result<PutObjectResult> {
6907+
let lock = self.bucket_write_lock(bucket);
6908+
let _guard = lock.lock().await;
6909+
let result = self
6910+
.put_object_flat_deferred_locked(
6911+
bucket,
6912+
key,
6913+
data,
6914+
content_type,
6915+
Some(progress),
6916+
Some(cancel),
6917+
)
68856918
.await?;
68866919
self.flush_forest_locked(bucket).await?;
68876920
Ok(result)
@@ -6917,7 +6950,7 @@ impl EncryptedClient {
69176950
) -> Result<PutObjectResult> {
69186951
let lock = self.bucket_write_lock(bucket);
69196952
let _guard = lock.lock().await;
6920-
self.put_object_flat_deferred_locked(bucket, key, data, content_type, None)
6953+
self.put_object_flat_deferred_locked(bucket, key, data, content_type, None, None)
69216954
.await
69226955
}
69236956

@@ -6935,6 +6968,7 @@ impl EncryptedClient {
69356968
data: impl Into<Bytes>,
69366969
content_type: Option<&str>,
69376970
progress: Option<Arc<dyn Fn(u64, u64) + Send + Sync>>,
6971+
cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
69386972
) -> Result<PutObjectResult> {
69396973
let data = data.into();
69406974
let original_size = data.len() as u64;
@@ -7037,6 +7071,7 @@ impl EncryptedClient {
70377071
&encrypted_meta,
70387072
kek_version,
70397073
progress,
7074+
cancel,
70407075
).await?
70417076
} else {
70427077
// SINGLE OBJECT: File is small enough for one block
@@ -7277,6 +7312,10 @@ impl EncryptedClient {
72777312
encrypted_meta: &EncryptedPrivateMetadata,
72787313
kek_version: u32,
72797314
progress: Option<Arc<dyn Fn(u64, u64) + Send + Sync>>,
7315+
// Cooperative cancel (web + native). `Some` = the chunk loop checks it
7316+
// between PUTs; `None` = uncancellable (existing callers). Mirrors the
7317+
// native resumable path's `Arc<AtomicBool>` flag.
7318+
cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
72807319
) -> Result<(PutObjectResult, String, Option<cid::Cid>)> {
72817320
// Create chunked encoder with AAD binding chunks to storage key
72827321
let aad_prefix = format!("fula:v4:chunk:{}", storage_key);
@@ -7338,8 +7377,19 @@ impl EncryptedClient {
73387377
let chunk_key_ret = chunk_key.clone();
73397378
let progress_cb = progress.clone();
73407379
let uploaded_chunks = uploaded_chunks.clone();
7380+
let cancel = cancel.clone();
73417381

73427382
async move {
7383+
// Cooperative cancel (mirrors the native resumable path): a
7384+
// chunk that hasn't started yet short-circuits when the flag is
7385+
// set; chunks already in flight (≤ MAX_CONCURRENT_CHUNK_UPLOADS)
7386+
// run to completion, and the post-loop compensating-delete
7387+
// cleans up everything that did upload.
7388+
if let Some(ref c) = cancel {
7389+
if c.load(std::sync::atomic::Ordering::Relaxed) {
7390+
return Err(ClientError::Cancelled);
7391+
}
7392+
}
73437393
// FxFiles #50: the content-chunk PUT had no retry on EITHER
73447394
// target — the native blob-backend retry only wraps forest
73457395
// nodes, not content chunks. One sporadic chunk-PUT drop
@@ -7450,6 +7500,18 @@ impl EncryptedClient {
74507500
return Err(err);
74517501
}
74527502

7503+
// Cancelled after the chunks uploaded but before the index PUT: the
7504+
// chunks are unreferenced (no index object points at them yet), so
7505+
// delete them and abort — same cleanup as the failure path above.
7506+
if let Some(ref c) = cancel {
7507+
if c.load(std::sync::atomic::Ordering::Relaxed) {
7508+
for key in &uploaded_keys {
7509+
let _ = self.inner.delete_object(bucket, key).await;
7510+
}
7511+
return Err(ClientError::Cancelled);
7512+
}
7513+
}
7514+
74537515
// W.9.4-A2: stamp the per-chunk CID Vec into the metadata
74547516
// BEFORE serializing the index body. When walkable_v8 is off,
74557517
// chunk_cids is all-None and `populate_chunk_cids` writes an

crates/fula-client/tests/chunk_put_progress.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,50 @@ async fn chunked_put_reports_cumulative_progress() {
8585
let max_done = evs.iter().map(|(d, _)| *d).max().unwrap();
8686
assert_eq!(max_done, total, "progress must reach 100% (max cumulative == total)");
8787
}
88+
89+
/// 0.6.14 wasm upload-cancel: a pre-set cancel flag must abort the chunked
90+
/// upload with `ClientError::Cancelled` — every chunk short-circuits at the
91+
/// closure-start check before its PUT (mirrors the native resumable cancel).
92+
#[tokio::test]
93+
async fn chunked_put_cancellable_aborts_when_flag_preset() {
94+
use std::sync::atomic::AtomicBool;
95+
96+
let server = MockServer::start().await;
97+
Mock::given(method("PUT")).respond_with(EtagResponder).mount(&server).await;
98+
Mock::given(method("GET"))
99+
.respond_with(ResponseTemplate::new(404))
100+
.mount(&server)
101+
.await;
102+
Mock::given(method("HEAD"))
103+
.respond_with(ResponseTemplate::new(200))
104+
.mount(&server)
105+
.await;
106+
107+
let mut config = Config::new(&server.uri()).with_token("test-jwt");
108+
config.walkable_v8_writer_enabled = true;
109+
let secret = SecretKey::generate();
110+
let client = EncryptedClient::new(config, EncryptionConfig::from_secret_key(secret))
111+
.expect("EncryptedClient::new");
112+
113+
// Multi-chunk file (2 MiB > 768 KB threshold).
114+
let data = vec![0xABu8; 2 * 1024 * 1024];
115+
// Pre-cancelled: the flag is already set before the upload starts.
116+
let cancel = Arc::new(AtomicBool::new(true));
117+
let progress: Arc<dyn Fn(u64, u64) + Send + Sync> = Arc::new(|_, _| {});
118+
119+
let result = client
120+
.put_object_flat_with_progress_cancellable(
121+
"videos-v8",
122+
"/cancelled.bin",
123+
Bytes::from(data),
124+
Some("application/octet-stream"),
125+
progress,
126+
cancel,
127+
)
128+
.await;
129+
130+
assert!(
131+
matches!(result, Err(fula_client::ClientError::Cancelled)),
132+
"a pre-set cancel flag must abort the chunked upload with Cancelled",
133+
);
134+
}

crates/fula-flutter/src/api/forest.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,37 @@ pub async fn put_flat_with_progress(
631631
Ok(result.into())
632632
}
633633

634+
/// [`put_flat_with_progress`] with cooperative cancellation (web + native).
635+
/// Non-resumable: triggering `cancel` aborts the in-flight upload (chunks
636+
/// already in flight finish, later chunks short-circuit, uploaded chunks are
637+
/// cleaned up) and the call returns a `Cancelled` error. A cancelled upload
638+
/// restarts from scratch — wasm resumable support lands separately. This is
639+
/// web's cancel path (the native resumable+manifest cancel is the
640+
/// `*_from_path` family, which is native-only).
641+
pub async fn put_flat_with_progress_cancellable(
642+
client: &EncryptedClientHandle,
643+
bucket: String,
644+
path: String,
645+
data: Vec<u8>,
646+
content_type: Option<String>,
647+
progress: &ProgressHandle,
648+
cancel: &CancelHandle,
649+
) -> anyhow::Result<PutResult> {
650+
let cb = progress_cb(progress);
651+
let guard = client.inner.write().await;
652+
let result = guard
653+
.put_object_flat_with_progress_cancellable(
654+
&bucket,
655+
&path,
656+
Bytes::from(data),
657+
content_type.as_deref(),
658+
cb,
659+
cancel.inner.clone(),
660+
)
661+
.await?;
662+
Ok(result.into())
663+
}
664+
634665
/// [`put_flat_resumable_from_path_cancellable`] with live progress (native).
635666
#[cfg(not(target_arch = "wasm32"))]
636667
pub async fn put_flat_resumable_from_path_with_progress(

0 commit comments

Comments
 (0)