feat(fs): preserve source mtime on synced files + document deprecated protocol variants

- Server restores client's modification time (from FsManifestEntry.mtime) after
  atomic write+rename, so tools that check mtimes (make, backup software, rsync)
  don't see every synced file as freshly written. Best-effort: silently ignored on
  FAT32 and read-only filesystems.
- Add session unit test verifying mtime is preserved within ±2s tolerance.
- Document FsCdcRequest/FsCdcNeed variants as deprecated stubs kept only because
  rkyv's append-only rule forbids removal; current protocol embeds server chunks
  directly in FsDirNeed, eliminating the per-file sub-exchange round-trip.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-05 12:01:43 -05:00
co-authored by Claude Sonnet 4.6
parent 0ed2f5054f
commit ad479324ca
2 changed files with 70 additions and 8 deletions
+55 -3
View File
@@ -410,6 +410,8 @@ impl FsSyncServer {
server_chunk_map: HashMap<u64, u64>,
expected_blake3: [u8; 32],
is_add: bool,
/// Unix mtime from the client manifest; restored after atomic write.
client_mtime: i64,
}
let mut file_ctx: HashMap<String, FileCtx> = HashMap::new();
@@ -451,11 +453,11 @@ impl FsSyncServer {
})
.collect();
let expected_blake3 = client_entries
let (expected_blake3, client_mtime) = client_entries
.iter()
.find(|e| e.path.as_str() == *rel_path)
.map(|e| e.blake3)
.unwrap_or([0u8; 32]);
.map(|e| (e.blake3, e.mtime))
.unwrap_or(([0u8; 32], 0));
needed_files.push(FsFileNeed {
path: rel_path.to_string(),
@@ -470,6 +472,7 @@ impl FsSyncServer {
server_chunk_map,
expected_blake3,
is_add,
client_mtime,
},
);
}
@@ -517,6 +520,7 @@ impl FsSyncServer {
let server_bytes = ctx.server_bytes;
let server_chunk_map = ctx.server_chunk_map;
let is_add = ctx.is_add;
let client_mtime = ctx.client_mtime;
let output = tokio::task::spawn_blocking(move || {
reconstruct_file(
@@ -554,6 +558,19 @@ impl FsSyncServer {
tokio::task::spawn_blocking(move || -> Result<(), FsSyncError> {
std::fs::write(&tmp_clone, &output_clone)?;
std::fs::rename(&tmp_clone, &abs_clone)?;
// Restore the client's modification time so downstream
// tools (make, rsync, backup software) don't see every
// synced file as "just changed".
if client_mtime != 0 {
use std::time::{Duration, SystemTime};
let mtime = SystemTime::UNIX_EPOCH
+ Duration::from_secs(client_mtime.max(0) as u64);
// Best-effort: ignore errors (FAT32, read-only fs, etc.)
let _ = std::fs::File::options()
.write(true)
.open(&abs_clone)
.and_then(|f| f.set_modified(mtime));
}
Ok(())
})
.await
@@ -854,4 +871,39 @@ mod tests {
assert!(would_remove.iter().any(|p| p == "server_only.bin"),
"server_only.bin not in would_remove: {would_remove:?}");
}
#[tokio::test]
async fn session_mtime_preserved_on_transfer() {
use std::time::{Duration, SystemTime};
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
// Write source file with a known mtime well in the past (2020-01-01 00:00:00 UTC).
let target_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_577_836_800);
let src_file = src.path().join("timestamped.bin");
fs::write(&src_file, vec![0xABu8; 4096]).unwrap();
fs::File::options()
.write(true)
.open(&src_file)
.unwrap()
.set_modified(target_mtime)
.unwrap();
run_sync_pair(src.path(), dst.path(), false).await;
let dst_file = dst.path().join("timestamped.bin");
assert!(dst_file.exists());
let dst_mtime = fs::metadata(&dst_file).unwrap().modified().unwrap();
// Allow ±2 s tolerance for filesystem mtime resolution.
let delta = if dst_mtime > target_mtime {
dst_mtime.duration_since(target_mtime).unwrap()
} else {
target_mtime.duration_since(dst_mtime).unwrap()
};
assert!(
delta <= Duration::from_secs(2),
"mtime not preserved: expected {target_mtime:?}, got {dst_mtime:?} (delta {delta:?})"
);
}
}
+15 -5
View File
@@ -167,17 +167,27 @@ pub enum SyncMessage {
//
// IMPORTANT: rkyv assigns discriminants positionally. These variants MUST
// remain appended after `Error` (index 8). Never insert or reorder.
/// Client announces it wants to sync a single file using CDC delta.
//
// Variants 9 and 10 (`FsCdcRequest` / `FsCdcNeed`) were part of an early
// per-file sub-exchange design and are no longer used. The current
// directory-sync protocol embeds server chunk hashes directly in
// `FsDirNeed::FsFileNeed::server_chunks`, eliminating a round-trip.
// These variants are preserved (cannot remove — rkyv is append-only) but
// are never sent or matched in live code.
/// **DEPRECATED** — not used by any current code path.
///
/// Carries the ordered CDC chunk descriptors of the client's version of
/// the file. Order defines the final file layout on the server.
/// Originally: client announces a single-file CDC sync, carrying its ordered
/// chunk descriptors. Superseded by the server chunk embedding in `FsDirNeed`.
FsCdcRequest {
path: String,
chunk_hashes: Vec<FsChunkHash>,
},
/// Server replies with the indices (positions in `FsCdcRequest::chunk_hashes`)
/// it needs transferred. Indices are sorted ascending.
/// **DEPRECATED** — not used by any current code path.
///
/// Originally: server replies with the indices it needs transferred.
/// Superseded by the server chunk embedding in `FsDirNeed`.
FsCdcNeed {
path: String,
needed_indices: Vec<u32>,