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>,
|
server_chunk_map: HashMap<u64, u64>,
|
||||||
expected_blake3: [u8; 32],
|
expected_blake3: [u8; 32],
|
||||||
is_add: bool,
|
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();
|
let mut file_ctx: HashMap<String, FileCtx> = HashMap::new();
|
||||||
@@ -451,11 +453,11 @@ impl FsSyncServer {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let expected_blake3 = client_entries
|
let (expected_blake3, client_mtime) = client_entries
|
||||||
.iter()
|
.iter()
|
||||||
.find(|e| e.path.as_str() == *rel_path)
|
.find(|e| e.path.as_str() == *rel_path)
|
||||||
.map(|e| e.blake3)
|
.map(|e| (e.blake3, e.mtime))
|
||||||
.unwrap_or([0u8; 32]);
|
.unwrap_or(([0u8; 32], 0));
|
||||||
|
|
||||||
needed_files.push(FsFileNeed {
|
needed_files.push(FsFileNeed {
|
||||||
path: rel_path.to_string(),
|
path: rel_path.to_string(),
|
||||||
@@ -470,6 +472,7 @@ impl FsSyncServer {
|
|||||||
server_chunk_map,
|
server_chunk_map,
|
||||||
expected_blake3,
|
expected_blake3,
|
||||||
is_add,
|
is_add,
|
||||||
|
client_mtime,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -517,6 +520,7 @@ impl FsSyncServer {
|
|||||||
let server_bytes = ctx.server_bytes;
|
let server_bytes = ctx.server_bytes;
|
||||||
let server_chunk_map = ctx.server_chunk_map;
|
let server_chunk_map = ctx.server_chunk_map;
|
||||||
let is_add = ctx.is_add;
|
let is_add = ctx.is_add;
|
||||||
|
let client_mtime = ctx.client_mtime;
|
||||||
|
|
||||||
let output = tokio::task::spawn_blocking(move || {
|
let output = tokio::task::spawn_blocking(move || {
|
||||||
reconstruct_file(
|
reconstruct_file(
|
||||||
@@ -554,6 +558,19 @@ impl FsSyncServer {
|
|||||||
tokio::task::spawn_blocking(move || -> Result<(), FsSyncError> {
|
tokio::task::spawn_blocking(move || -> Result<(), FsSyncError> {
|
||||||
std::fs::write(&tmp_clone, &output_clone)?;
|
std::fs::write(&tmp_clone, &output_clone)?;
|
||||||
std::fs::rename(&tmp_clone, &abs_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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -854,4 +871,39 @@ mod tests {
|
|||||||
assert!(would_remove.iter().any(|p| p == "server_only.bin"),
|
assert!(would_remove.iter().any(|p| p == "server_only.bin"),
|
||||||
"server_only.bin not in would_remove: {would_remove:?}");
|
"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
|
// IMPORTANT: rkyv assigns discriminants positionally. These variants MUST
|
||||||
// remain appended after `Error` (index 8). Never insert or reorder.
|
// 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
|
/// Originally: client announces a single-file CDC sync, carrying its ordered
|
||||||
/// the file. Order defines the final file layout on the server.
|
/// chunk descriptors. Superseded by the server chunk embedding in `FsDirNeed`.
|
||||||
FsCdcRequest {
|
FsCdcRequest {
|
||||||
path: String,
|
path: String,
|
||||||
chunk_hashes: Vec<FsChunkHash>,
|
chunk_hashes: Vec<FsChunkHash>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Server replies with the indices (positions in `FsCdcRequest::chunk_hashes`)
|
/// **DEPRECATED** — not used by any current code path.
|
||||||
/// it needs transferred. Indices are sorted ascending.
|
///
|
||||||
|
/// Originally: server replies with the indices it needs transferred.
|
||||||
|
/// Superseded by the server chunk embedding in `FsDirNeed`.
|
||||||
FsCdcNeed {
|
FsCdcNeed {
|
||||||
path: String,
|
path: String,
|
||||||
needed_indices: Vec<u32>,
|
needed_indices: Vec<u32>,
|
||||||
|
|||||||
Reference in New Issue
Block a user