use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsValue;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use futures::StreamExt;
use libfw_core::compress::{CompressionFormat, compressor_with_level};
use libfw_core::metadata::encode_file_meta_header;
use libfw_core::{
HEADER_COMPRESS, HEADER_FILE_META, HEADER_FINAL, HEADER_OFFSET, HEADER_SESSION,
HEADER_SESSION_STATUS,
};
use crate::config::ClientConfig;
use crate::error::LibfwError;
use crate::http::{auth_headers, fetch, file_url, read_all, request, xhr_post};
use crate::js::Callbacks;
use crate::plan::{chunk_bounds, total_bytes, FileEntry};
use crate::state::{Semaphore, TaskControl};
use crate::tune::{LEVEL_SAMPLE_SIZE, TuneEvent, TuneHandle, now_ms};
pub(crate) fn tune_tick(
tune: &TuneHandle,
control: &TaskControl,
done_bytes: u64,
rtt_ms: Option<f64>,
error: bool,
) -> Option<TuneEvent> {
let event = tune.borrow_mut().tick(now_ms(), done_bytes, rtt_ms, error);
if let Some(ev) = &event {
control.set_max_parallel(ev.params.request_budget());
}
event
}
pub(crate) async fn sleep_ms(ms: u32) {
if ms == 0 {
return;
}
let promise = js_sys::Promise::new(&mut |resolve, _reject| {
let Some(window) = web_sys::window() else { return };
let f: &js_sys::Function = resolve.unchecked_ref();
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(f, ms as i32);
});
let _ = JsFuture::from(promise).await;
}
#[allow(clippy::too_many_arguments)]
async fn post_chunk(
base_url: &str,
token: &str,
file: &FileEntry,
offset: u64,
body: &[u8],
compress: bool,
timeout_ms: u32,
final_chunk: bool,
session: &str,
semaphore: &Semaphore,
on_progress: Option<Rc<dyn Fn(u64, u64)>>,
) -> Result<(), LibfwError> {
let mut headers: Vec<(String, String)> = vec![
("Authorization".to_string(), format!("Bearer {token}")),
(
libfw_core::HEADER_PROTOCOL.to_string(),
libfw_core::protocol_header_value().to_string(),
),
];
headers.push((HEADER_OFFSET.to_string(), offset.to_string()));
headers.push((HEADER_FILE_META.to_string(), encode_file_meta_header(&file.to_meta())));
if !session.is_empty() {
headers.push((HEADER_SESSION.to_string(), session.to_string()));
}
if compress && !body.is_empty() {
headers.push((HEADER_COMPRESS.to_string(), "zrip".to_string()));
}
if final_chunk {
headers.push((HEADER_FINAL.to_string(), "1".to_string()));
}
let url = file_url(base_url, &file.path);
let _permit = semaphore.acquire().await;
let promise = xhr_post(&url, &headers, body, timeout_ms, on_progress)?;
let status = JsFuture::from(promise)
.await
.map_err(|e| LibfwError::Network(format!("upload request failed: {e:?}")))?
.as_f64()
.unwrap_or(0.0) as u16;
if status == 201 {
Ok(())
} else {
Err(LibfwError::Http { status, url })
}
}
fn session_id_for(file: &FileEntry) -> String {
file.to_meta().etag.trim_matches('"').to_string()
}
async fn probe_session(
base_url: &str,
token: &str,
file: &FileEntry,
session: &str,
timeout_ms: u32,
) -> Result<Option<Vec<(u64, u64)>>, LibfwError> {
let headers = auth_headers(token, false, None)?;
headers
.set(HEADER_OFFSET, "0")
.map_err(|e| LibfwError::Js(format!("set offset header failed: {e:?}")))?;
headers
.set(HEADER_FILE_META, &encode_file_meta_header(&file.to_meta()))
.map_err(|e| LibfwError::Js(format!("set meta header failed: {e:?}")))?;
headers
.set(HEADER_SESSION, session)
.map_err(|e| LibfwError::Js(format!("set session header failed: {e:?}")))?;
headers
.set(HEADER_SESSION_STATUS, "1")
.map_err(|e| LibfwError::Js(format!("set session-status header failed: {e:?}")))?;
let url = file_url(base_url, &file.path);
let (req, ctrl) = request(&url, "POST", &headers, None)?;
let resp = fetch(&req, timeout_ms, &ctrl).await?;
let status = resp.status();
if status != 200 && status != 201 {
return Err(LibfwError::Http { status, url });
}
let body = read_all(&resp, timeout_ms, &ctrl).await?;
#[derive(serde::Deserialize)]
struct Ranges {
#[serde(default)]
ranges: Vec<[u64; 2]>,
}
let parsed: Ranges = serde_json::from_slice(&body)
.map_err(|e| LibfwError::Protocol(format!("bad session-status JSON: {e}")))?;
Ok(Some(
parsed
.ranges
.into_iter()
.map(|[s, e]| (s, e.max(s)))
.collect(),
))
}
pub(crate) fn aligned_missing(
file: &FileEntry,
chunk_size: u64,
received: &[(u64, u64)],
) -> Vec<(u64, u64)> {
let mut missing = Vec::new();
for (start, end) in chunk_bounds(file, chunk_size, 0) {
missing.extend(missing_ranges(start, end, received));
}
missing
}
pub(crate) fn missing_ranges(start: u64, end: u64, received: &[(u64, u64)]) -> Vec<(u64, u64)> {
let mut missing = vec![(start, end)];
for range in received {
let (rs0, re0) = *range;
let rs = rs0.max(start);
let re = re0.min(end).max(rs);
if re <= rs {
continue;
}
let mut next = Vec::with_capacity(missing.len() + 1);
for (ms, me) in missing {
if re <= ms || rs >= me {
next.push((ms, me));
} else {
if ms < rs {
next.push((ms, rs));
}
if re < me {
next.push((re, me));
}
}
}
missing = next;
}
missing
}
async fn upload_one_chunk(
base_url: &str,
token: &str,
file: &FileEntry,
callbacks: &Callbacks,
control: &TaskControl,
tune: &TuneHandle,
config: &ClientConfig,
start: u64,
end: u64,
session: &str,
compress: bool,
level: i32,
) -> Result<u64, LibfwError> {
control.wait_ready().await?;
control.check()?;
let len = end - start;
let raw = callbacks.read_file(&file.path, start, len).await?;
if raw.len() as u64 != len {
return Err(LibfwError::Storage(format!(
"read {} of {} bytes for `{}`",
raw.len(),
len,
file.path
)));
}
let payload: Vec<u8> = if compress {
let mut enc = compressor_with_level(CompressionFormat::Zrip, level)
.map_err(|e| LibfwError::Compress(e.to_string()))?;
let mut out = Vec::with_capacity(raw.len());
for window in raw.chunks(libfw_core::STREAM_BUF_SIZE) {
enc.compress(window, &mut out)
.map_err(|e| LibfwError::Compress(e.to_string()))?;
}
enc.finish(&mut out)
.map_err(|e| LibfwError::Compress(e.to_string()))?;
out
} else {
raw
};
let mut attempts = 0u32;
let last_loaded = Rc::new(Cell::new(0u64));
let on_progress = {
let control = control.clone();
let callbacks = callbacks.clone();
let last_loaded = last_loaded.clone();
Some(Rc::new(move |loaded: u64, _total: u64| {
let prev = last_loaded.get();
if loaded > prev {
last_loaded.set(loaded);
control.add_progress(loaded - prev);
let _ = control.report_progress_if(&callbacks);
}
}) as Rc<dyn Fn(u64, u64)>)
};
loop {
control.wait_ready().await?;
control.check()?;
match post_chunk(
base_url,
token,
file,
start,
&payload,
compress,
config.timeout_ms,
false,
session,
control.semaphore(),
on_progress.clone(),
)
.await
{
Ok(()) => break,
Err(e) => {
if attempts >= config.max_retries {
return Err(e);
}
attempts += 1;
callbacks.log(&format!(
"retrying chunk {start}..{end} of `{}` (attempt {attempts}): {e}",
file.path
));
sleep_ms(config.backoff_ms(attempts)).await;
}
}
}
let counted = last_loaded.get().min(len);
if counted < len {
control.add_progress(len - counted);
}
control.report_progress_if(callbacks)?;
if tune.borrow().enabled() {
tune_tick(tune, control, control.done_bytes(), None, false);
}
Ok(len)
}
async fn commit_upload(
base_url: &str,
token: &str,
file: &FileEntry,
callbacks: &Callbacks,
control: &TaskControl,
config: &ClientConfig,
session: &str,
) -> Result<(), LibfwError> {
let mut attempts = 0u32;
loop {
control.wait_ready().await?;
control.check()?;
match post_chunk(
base_url,
token,
file,
file.size,
&[],
false,
config.timeout_ms,
true,
session,
control.semaphore(),
None, )
.await
{
Ok(()) => return Ok(()),
Err(e) => {
if attempts >= config.max_retries {
return Err(e);
}
attempts += 1;
callbacks.log(&format!(
"retrying commit for `{}` (attempt {attempts}): {e}",
file.path
));
sleep_ms(config.backoff_ms(attempts)).await;
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn upload_session_resumable(
base_url: &str,
token: &str,
file: &FileEntry,
callbacks: &Callbacks,
control: &TaskControl,
config: &ClientConfig,
tune: &TuneHandle,
compress: bool,
level: i32,
) -> Result<u64, LibfwError> {
let session = session_id_for(file);
let mut received = probe_session(base_url, token, file, &session, config.timeout_ms).await?
.unwrap_or_default();
let initial_covered = covered_bytes(&received).min(file.size);
if initial_covered > 0 {
control.add_progress(initial_covered);
control.report_progress_if(callbacks)?;
}
if initial_covered >= file.size {
callbacks.log(&format!(
"upload `{}` already fully present on the server; skipping re-send ({} bytes already covered)",
file.path, initial_covered
));
}
let mut rounds = 0u32;
let mut first_error: Option<LibfwError> = None;
let uploaded = loop {
control.wait_ready().await?;
control.check()?;
let chunk_size = tune.borrow().params().chunk_size.max(1);
if rounds > 0 {
received = probe_session(base_url, token, file, &session, config.timeout_ms).await?
.unwrap_or_default();
}
let missing = aligned_missing(file, chunk_size, &received);
if missing.is_empty() {
match commit_upload(base_url, token, file, callbacks, control, config, &session)
.await
{
Ok(()) => {
if tune.borrow().enabled() {
tune_tick(tune, control, control.done_bytes(), None, false);
}
break file.size.saturating_sub(initial_covered);
}
Err(e) => {
if rounds >= config.max_retries {
return Err(e);
}
rounds += 1;
callbacks.log(&format!(
"commit failed for `{}`; re-verifying server state: {e}",
file.path
));
first_error.get_or_insert(e);
continue;
}
}
}
let mut queue: std::collections::VecDeque<(u64, u64)> = missing.into();
let mut round_errors = 0u32;
while !queue.is_empty() {
let window = tune.borrow().params().upload_window.max(1);
let batch: Vec<(u64, u64)> = queue.drain(..queue.len().min(window)).collect();
let mut stream = futures::stream::iter(batch.into_iter().map(|(start, end)| {
let base_url = base_url.to_string();
let token = token.to_string();
let file = file.clone();
let callbacks = callbacks.clone();
let control = control.clone();
let config = config.clone();
let tune = tune.clone();
let session = session.clone();
async move {
upload_one_chunk(
&base_url, &token, &file, &callbacks, &control, &tune, &config, start, end,
&session, compress, level,
)
.await
}
}))
.buffer_unordered(window);
while let Some(res) = stream.next().await {
if let Err(e) = res {
round_errors += 1;
first_error.get_or_insert(e);
}
}
}
match commit_upload(base_url, token, file, callbacks, control, config, &session).await {
Ok(()) => {
if tune.borrow().enabled() {
tune_tick(tune, control, control.done_bytes(), None, round_errors > 0);
}
break file.size.saturating_sub(initial_covered);
}
Err(e) => {
rounds += 1;
if rounds > config.max_retries {
let received = probe_session(
base_url,
token,
file,
&session,
config.timeout_ms,
)
.await?
.unwrap_or_default();
if aligned_missing(file, chunk_size, &received).is_empty() {
commit_upload(base_url, token, file, callbacks, control, config, &session)
.await?;
break file.size.saturating_sub(initial_covered);
}
return Err(first_error.unwrap_or_else(|| {
LibfwError::Protocol(format!(
"upload of `{}` did not converge after {rounds} rounds",
file.path
))
}));
}
callbacks.log(&format!(
"commit failed for `{}`; re-verifying server state: {e}",
file.path
));
first_error.get_or_insert(e);
}
}
if tune.borrow().enabled() {
tune_tick(tune, control, control.done_bytes(), None, round_errors > 0);
}
};
callbacks
.save_state(
"upload",
&file.path,
&state_json(file.size, &file.to_meta().etag, file.size),
)
.await?;
Ok(uploaded)
}
fn covered_bytes(received: &[(u64, u64)]) -> u64 {
let mut ranges: Vec<(u64, u64)> = received
.iter()
.map(|&(s, e)| (s.min(e), s.max(e)))
.filter(|&(s, e)| e > s)
.collect();
ranges.sort_by_key(|&(s, _)| s);
let mut total = 0u64;
let mut cur: Option<(u64, u64)> = None;
for (s, e) in ranges {
match cur {
None => cur = Some((s, e)),
Some((cs, ce)) => {
if s <= ce {
cur = Some((cs, ce.max(e)));
} else {
total = total.saturating_add(ce.saturating_sub(cs));
cur = Some((s, e));
}
}
}
}
if let Some((cs, ce)) = cur {
total = total.saturating_add(ce.saturating_sub(cs));
}
total
}
#[allow(clippy::too_many_arguments)]
async fn upload_file(
base_url: &str,
token: &str,
file: &FileEntry,
callbacks: &Callbacks,
control: &TaskControl,
config: &ClientConfig,
tune: &TuneHandle,
compress: bool,
level: i32,
) -> Result<u64, LibfwError> {
callbacks.on_file_start(&file.path, file.size)?;
let uploaded =
upload_session_resumable(base_url, token, file, callbacks, control, config, tune, compress, level)
.await?;
callbacks.on_file_completed(&file.path).await?;
Ok(uploaded)
}
fn state_json(offset: u64, etag: &str, size: u64) -> JsValue {
let state = js_sys::Object::new();
let _ = js_sys::Reflect::set(
&state,
&JsValue::from_str("offset"),
&JsValue::from_f64(offset as f64),
);
let _ = js_sys::Reflect::set(&state, &JsValue::from_str("etag"), &JsValue::from_str(etag));
let _ = js_sys::Reflect::set(&state, &JsValue::from_str("size"), &JsValue::from_f64(size as f64));
state.into()
}
pub async fn upload(
base_url: &str,
token: &str,
callbacks: &Callbacks,
control: &TaskControl,
config: &ClientConfig,
tune: &TuneHandle,
) -> Result<u64, LibfwError> {
let files = callbacks.file_list().await?;
let total = total_bytes(&files);
control.set_total(total);
callbacks.on_progress(0, total)?;
let caps = {
let t = tune.borrow();
t.caps().unwrap_or_default()
};
let (compress, level) = if config.compress {
let enabled = {
let t = tune.borrow();
t.enabled()
};
let sample = if enabled {
match files.first() {
Some(f) if f.size > 0 => {
let len = (f.size as usize).min(LEVEL_SAMPLE_SIZE);
callbacks
.read_file(&f.path, 0, len as u64)
.await
.ok()
.filter(|b| !b.is_empty())
}
_ => None,
}
} else {
None
};
let stats_mbps = {
let t = tune.borrow();
t.stats().mbps
};
let level = {
let mut t = tune.borrow_mut();
t.upload_compress_level(&caps, sample.as_deref(), stats_mbps)
};
(true, level)
} else {
(false, caps.compression.zrip_levels.default)
};
let mut stream = futures::stream::iter(files.into_iter().map(|file| {
let base_url = base_url.to_string();
let token = token.to_string();
let callbacks = callbacks.clone();
let control = control.clone();
let config = config.clone();
let tune = tune.clone();
async move {
upload_file(
&base_url, &token, &file, &callbacks, &control, &config, &tune, compress, level,
)
.await
}
}))
.buffer_unordered(tune.borrow().params().concurrency);
let mut done = 0u64;
while let Some(result) = stream.next().await {
match result {
Ok(bytes) => done = done.saturating_add(bytes),
Err(e) => {
if tune.borrow().enabled() {
tune_tick(tune, control, control.done_bytes(), None, true);
}
return Err(e);
}
}
let total = control.total_bytes();
let reported_done = if total == 0 {
control.done_bytes()
} else {
control.done_bytes().min(total)
};
callbacks.on_progress(reported_done, total)?;
}
Ok(done)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aligned_missing_covers_full_file_when_nothing_received() {
let f = FileEntry {
path: "f.bin".into(),
size: 10,
mtime: 1,
};
let missing = aligned_missing(&f, 4, &[]);
assert_eq!(missing, vec![(0, 4), (4, 8), (8, 10)]);
}
#[test]
fn aligned_missing_only_gaps() {
let f = FileEntry {
path: "f.bin".into(),
size: 20,
mtime: 1,
};
let missing = aligned_missing(&f, 4, &[(0, 4), (8, 12)]);
assert_eq!(missing, vec![(4, 8), (12, 16), (16, 20)]);
}
#[test]
fn aligned_missing_empty_when_fully_received() {
let f = FileEntry {
path: "f.bin".into(),
size: 12,
mtime: 1,
};
let missing = aligned_missing(&f, 4, &[(0, 12)]);
assert!(missing.is_empty());
}
#[test]
fn missing_ranges_none_when_fully_received() {
let received = vec![(0, 100)];
assert!(missing_ranges(0, 100, &received).is_empty());
}
#[test]
fn missing_ranges_reports_only_gaps() {
let received = vec![(0, 40), (60, 100)];
let missing = missing_ranges(0, 100, &received);
assert_eq!(missing, vec![(40, 60)]);
}
#[test]
fn missing_ranges_splits_partial_coverage() {
let received = vec![(8, 16)];
let missing = missing_ranges(0, 20, &received);
assert_eq!(missing, vec![(0, 8), (16, 20)]);
}
#[test]
fn missing_ranges_out_of_scope_received_ignored() {
let received = vec![(100, 200)];
let missing = missing_ranges(0, 20, &received);
assert_eq!(missing, vec![(0, 20)]);
}
#[test]
fn session_id_is_stable_and_safe() {
let a = FileEntry {
path: "dir/f.bin".into(),
size: 1024,
mtime: 42,
};
let id = session_id_for(&a);
let again = FileEntry {
path: "dir/f.bin".into(),
size: 1024,
mtime: 42,
};
assert_eq!(session_id_for(&again), id);
assert!(id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
let changed = FileEntry {
path: "dir/f.bin".into(),
size: 2048,
mtime: 42,
};
assert_ne!(session_id_for(&changed), id);
}
#[test]
fn covered_bytes_merges_overlaps() {
assert_eq!(covered_bytes(&[(0, 4), (8, 12)]), 8);
assert_eq!(covered_bytes(&[(0, 10), (5, 15)]), 15);
assert_eq!(covered_bytes(&[(0, 10), (10, 20)]), 20);
assert_eq!(covered_bytes(&[(0, 100), (40, 60), (60, 100)]), 100);
assert_eq!(covered_bytes(&[(5, 5)]), 0);
}
}