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:
co-authored by
Claude Sonnet 4.6
parent
0ed2f5054f
commit
ad479324ca
@@ -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:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user