Files
Marcos Paulo 9bd7dc931e
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: chunked resumable uploads in the sync driver and the phone sync sheet
- engine: Remote gains missing_chunks, put_chunk and put_chunked; upload_chunked streams a big file in 4 MiB chunks, sends only the chunks the server lacks, retries with backoff and reports progress (SyncStatus.transfer); the scan hashes by streaming and skips only files over 1 GiB
- engine: server.devices command and a sync.attachments option (non-Markdown files are skipped when it is off)
- ui-egui: the sync pill opens a bottom sheet on a phone-width window (status, devices, conflicts row, options, Sync now); wider windows keep Settings > Sync
- tests: chunk resume against the real server, 6 driven sheet tests, devices stub tests

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:57:07 -03:00

240 lines
9.9 KiB
Rust

//! The real engine sync client against the real server: a big file travels in
//! chunks, a cut connection resumes with the chunks that never arrived.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use notescraft_engine::{Changes, HttpRemote, Limits, Progress, PutReply, Remote, SyncError, SyncOptions, SyncReport, SyncResult, SyncState, sync_once_with};
use notescraft_sync_server::{AppState, Store, router};
use notescraft_vault::Vault;
const ADMIN: &str = "test-admin-token-0123456789abcdef";
/// The size of one chunk the client sends.
const CHUNK: usize = 32 * 1024;
/// The size above which the client sends a file in chunks.
const THRESHOLD: u64 = 16 * 1024;
/// The biggest chunk the server takes.
const SERVER_CHUNK_MAX: usize = 64 * 1024;
/// The biggest file the server assembles.
const SERVER_FILE_CAP: u64 = 1024 * 1024;
/// The size of the file that travels.
const FILE_BYTES: usize = 300 * 1024;
/// A temp folder that is empty when the test starts.
fn temp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("nc-chunk-e2e-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("dir");
dir
}
/// Starts the server on an ephemeral port and returns its base url.
fn start(data: &Path) -> String {
std::fs::create_dir_all(data.join("blobs")).expect("blobs");
let state = AppState::new(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN);
// The chunk limits are set before the router is built: the route takes its
// body cap from the state.
state.set_chunk_limits(SERVER_CHUNK_MAX, SERVER_FILE_CAP);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
listener.set_nonblocking(true).expect("nonblocking");
let port = listener.local_addr().expect("addr").port();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("runtime");
runtime.block_on(async {
let listener = tokio::net::TcpListener::from_std(listener).expect("listener");
axum::serve(listener, router(state)).await.ok();
});
});
format!("http://127.0.0.1:{port}")
}
fn admin(base: &str, path: &str) -> serde_json::Value {
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut response = agent.post(format!("{base}{path}")).header("Authorization", format!("Bearer {ADMIN}")).send_empty().expect("admin call");
response.body_mut().read_json().expect("json")
}
/// Issues a pairing code for the vault, bound to `user` when given.
fn issue(base: &str, vault_id: &str, user: Option<&str>) -> String {
let path = user.map_or_else(|| format!("/v1/vaults/{vault_id}/pairing"), |user| format!("/v1/vaults/{vault_id}/pairing?user={user}"));
admin(base, &path)["code"].as_str().expect("code").to_owned()
}
/// Redeems a code with raw HTTP to also learn the account the device joined.
fn pair_raw(base: &str, code: &str, name: &str) -> (String, String) {
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut response = agent.post(format!("{base}/v1/devices")).send_json(serde_json::json!({"code": code, "name": name})).expect("pair");
let device: serde_json::Value = response.body_mut().read_json().expect("json");
(device["token"].as_str().expect("token").to_owned(), device["user_id"].as_str().expect("user id").to_owned())
}
/// A remote that can cut the connection in the middle of a chunked upload.
struct CutRemote {
inner: HttpRemote,
/// How many chunks the client sent.
sent: Mutex<usize>,
/// From this send on, the connection is cut.
cut_from: Mutex<Option<usize>>,
}
impl CutRemote {
fn new(inner: HttpRemote) -> Self {
Self { inner, sent: Mutex::new(0), cut_from: Mutex::new(None) }
}
/// Cuts the connection from the given chunk send on.
fn cut_from(&self, call: usize) {
*self.cut_from.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(call);
}
/// Lets the connection through again.
fn mend(&self) {
*self.cut_from.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// How many chunks were sent since the last count.
fn take_sent(&self) -> usize {
let mut sent = self.sent.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *sent)
}
}
impl Remote for CutRemote {
fn changes(&self, since: u64, limit: usize, wait: u64) -> SyncResult<Changes> {
self.inner.changes(since, limit, wait)
}
fn get(&self, path: &str) -> SyncResult<Vec<u8>> {
self.inner.get(path)
}
fn put(&self, path: &str, bytes: &[u8], base_seq: u64, mtime_ms: u64) -> SyncResult<PutReply> {
self.inner.put(path, bytes, base_seq, mtime_ms)
}
fn delete(&self, path: &str, base_seq: u64) -> SyncResult<PutReply> {
self.inner.delete(path, base_seq)
}
fn missing_chunks(&self, hashes: &[String]) -> SyncResult<Vec<String>> {
self.inner.missing_chunks(hashes)
}
fn put_chunk(&self, hash: &str, bytes: &[u8]) -> SyncResult<()> {
let mut sent = self.sent.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*sent = sent.saturating_add(1);
let cut = *self.cut_from.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cut.is_some_and(|from| *sent >= from) {
return Err(SyncError::Http("the connection was cut".to_owned()));
}
drop(sent);
self.inner.put_chunk(hash, bytes)
}
fn put_chunked(&self, path: &str, chunk_hashes: &[String], content_hash: &str, base_seq: u64, mtime_ms: u64) -> SyncResult<PutReply> {
self.inner.put_chunked(path, chunk_hashes, content_hash, base_seq, mtime_ms)
}
}
/// A device with its own vault, its own state and a remote that can cut.
struct Device {
vault: Vault,
remote: CutRemote,
state: SyncState,
name: &'static str,
}
impl Device {
fn new(base: &str, vault_id: &str, token: &str, name: &'static str) -> Self {
let root = temp(name);
let vault = Vault::open(&root).expect("vault");
Self { vault, remote: CutRemote::new(HttpRemote::new(base, vault_id, token)), state: SyncState::default(), name }
}
fn write(&self, path: &str, bytes: &[u8]) {
self.vault.write(path, bytes).expect("write");
}
fn read(&self, path: &str) -> Vec<u8> {
self.vault.read(path).expect("read")
}
/// Syncs once and returns the report.
fn sync(&mut self) -> SyncReport {
let options = SyncOptions { limits: Limits { threshold: THRESHOLD, chunk: CHUNK }, progress: Box::new(|_: &Progress| {}) };
sync_once_with(&self.vault, &self.remote, &mut self.state, self.name, "2026-10-20", options).expect("sync")
}
}
/// Bytes that look random but come from a small generator, so the test is stable.
fn random_bytes(len: usize, seed: u64) -> Vec<u8> {
let mut state = seed;
(0..len)
.map(|_| {
state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
u8::try_from((state >> 33) & 0xff).expect("byte")
})
.collect()
}
/// How many chunks the server holds for the vault.
fn stored_chunks(blobs: &Path, vault_id: &str) -> usize {
let dir = blobs.join(vault_id).join("chunks");
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
entries.filter_map(std::result::Result::ok).filter(|entry| entry.path().is_file()).count()
}
#[test]
fn a_big_file_travels_in_chunks_and_a_cut_resumes_with_the_rest() {
let data = temp("server-data");
let base = start(&data);
let blobs = data.join("blobs");
let vault_id = format!("v-chunk-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()));
let (phone_token, user_id) = pair_raw(&base, &issue(&base, &vault_id, None), "phone");
let laptop = pair_raw(&base, &issue(&base, &vault_id, Some(&user_id)), "laptop");
let mut phone = Device::new(&base, &vault_id, &phone_token, "phone");
let mut laptop = Device::new(&base, &vault_id, &laptop.0, "laptop");
// A file that is too big for one request travels in chunks.
let bytes = random_bytes(FILE_BYTES, 42);
phone.write("big.bin", &bytes);
let report = phone.sync();
assert_eq!(report.uploaded, vec!["big.bin".to_owned()]);
let chunks = FILE_BYTES.div_ceil(CHUNK);
assert_eq!(stored_chunks(&blobs, &vault_id), chunks, "the server holds every chunk");
// The other device gets the very same bytes.
let report = laptop.sync();
assert_eq!(report.downloaded, vec!["big.bin".to_owned()]);
assert_eq!(laptop.read("big.bin"), bytes);
// The connection dies on the fourth chunk of the next file.
let other = random_bytes(FILE_BYTES, 7);
phone.write("other.bin", &other);
phone.remote.take_sent();
phone.remote.cut_from(4);
let error = sync_once_with(
&phone.vault,
&phone.remote,
&mut phone.state,
"phone",
"2026-10-20",
SyncOptions { limits: Limits { threshold: THRESHOLD, chunk: CHUNK }, progress: Box::new(|_: &Progress| {}) },
)
.expect_err("cut");
assert!(matches!(error, SyncError::Http(_)), "{error:?}");
phone.remote.take_sent();
assert_eq!(stored_chunks(&blobs, &vault_id), chunks + 3, "the three chunks that arrived are kept");
// The next pass sends the chunks that never arrived and nothing else.
phone.remote.mend();
phone.sync();
assert_eq!(phone.remote.take_sent(), chunks - 3, "only the missing chunks travel again");
assert_eq!(stored_chunks(&blobs, &vault_id), chunks * 2, "the server holds every chunk of both files");
// The other device converges on the second file too.
laptop.sync();
assert_eq!(laptop.read("other.bin"), other);
assert_eq!(laptop.read("big.bin"), bytes);
}