Sync Process¶
This page describes how ClientUpdater.UpdateClient() decides what to do with each file, in the order it does it.
Phases¶
A sync is divided into four phases, each timed independently:
flowchart LR
A[Scan] --> B[Compare/Copy] --> C[Cleanup] --> D[Launch]
1. Scan phase¶
Directory.EnumerateFiles(serverDirectory, "*", SearchOption.AllDirectories) walks the entire server tree and produces a flat list of file paths.
This used to be a manual recursive walk with Directory.GetFiles and Directory.GetDirectories calls. The change to EnumerateFiles(AllDirectories) roughly halves the SMB round-trip count on network shares, because the OS streams file entries from the server in fewer batches instead of issuing a separate query per subdirectory.
The scan phase is logged with both elapsed time and per-file average:
[INFO ] Scan phase: 154 files found in 11 ms (0.1 ms/file).
A healthy local network gives sub-millisecond per-file. Anything over 5ms suggests SMB latency, antivirus inspection, or VPN overhead.
2. Compare/copy phase¶
For each server file, the updater decides whether to copy. The logic is tiered, fastest checks first:
flowchart TD
Start([For each server file]) --> Exempt{Copy<br/>exemption?}
Exempt -->|Yes| Skip[Skip + log reason]
Exempt -->|No| Length{Lengths<br/>match?}
Length -->|No| Copy[Copy with retry]
Length -->|Yes| Time{Source<br/>newer than<br/>dest by >1s?}
Time -->|Yes| Copy
Time -->|No| SkipFile[Skip — assumed identical]
Copy --> Verify[Verify post-copy]
Verify --> Success([Counted as copied])
Copy exemptions¶
Three reasons a file is skipped at copy time:
- Matches the running launcher's filename — never copy the launcher into the local folder.
- Has a shortcut file extension (.lnk, .url) — shortcuts on the server are typically there for the developers, not for users' local installs.
- Lives in an exempt subfolder (Settings, Pulse Logs) AND the destination already exists — once these folders exist locally, the launcher never overwrites them. Initial population is allowed.
Each exemption is logged explicitly:
[INFO ] Copy exemption: \\server\share\Pulse Dashboard Client.exe (matches running launcher)
Comparison logic¶
After exemptions, the actual comparison:
| Step | Decision |
|---|---|
| Destination doesn't exist | Different → copy |
| Lengths differ | Different → copy |
Source's LastWriteTimeUtc exceeds dest's by more than 1 second |
Different → copy |
| Otherwise | Identical → skip |
The 1-second tolerance is necessary because filesystems disagree on timestamp precision. NTFS keeps ~100ns; SMB sometimes rounds; FAT was 2-second resolution. Without tolerance, byte-identical files would appear "different" to the comparator and get recopied on every launch.
We deliberately do not read PE headers (FileVersionInfo) for assemblies on the hot path. Doing so would mean reading bytes from the network for every DLL on every launch — a major slowdown for negligible benefit.
Copy with retry and verification¶
When a file actually needs copying:
1. Ensure the destination directory exists (create if missing).
2. Clear any read-only attribute on the destination.
3. Up to 3 attempts of File.Copy(source, dest, overwrite:=True), with 500ms between attempts.
4. After each successful copy, verify it.
The verification depends on validation mode:
- Standard mode (default): length check after copy. If dest.Length != source.Length, treat as failed copy.
- Strict mode (/verify flag): length check + SHA1 hash comparison.
A failed copy after all retries is recorded in result.Failures with the specific exception message. Persistent failures surface to the user and block the application launch.
3. Cleanup phase¶
Two passes:
Files¶
Walk the local tree. For each local file:
| Condition | Action |
|---|---|
Is ServerPath.txt |
Preserve |
| Is the currently running launcher | Preserve |
Lives in an exempt subfolder (Settings, Pulse Logs) |
Preserve |
| Path doesn't appear in the expected-paths set | Delete |
| Otherwise | Leave alone |
The expected-paths set is computed from the server file list (mapped to client-side paths). Any local file not in this set is an orphan — left over from a previous version of the application — and gets deleted.
Folders¶
After file cleanup, the launcher walks subfolders:
flowchart TD
Start([For each client subfolder]) --> Exempt{Exempt name?<br/>Settings, Pulse Logs}
Exempt -->|Yes| Skip[Skip — don't recurse]
Exempt -->|No| Server{Corresponding<br/>server folder<br/>exists?}
Server -->|Yes| Recurse[Recurse into it]
Server -->|No| Delete[Delete entire subtree]
This catches the case where a server-side folder used to exist but has been removed in a newer version. Without this, those local subtrees would persist as ghost folders forever.
Post-delete verification¶
Both passes use a "zero-cost integrity check": after File.Delete or Directory.Delete, immediately call File.Exists/Directory.Exists to confirm the deletion actually happened. This catches the rare case where delete returns success but the item is still there (caching, AV interference, etc.).
Failed deletions don't block launch — they're logged at ERROR level and recorded in result.DeletionFailureCount. A persistent local orphan is annoying but not catastrophic; the application will still run correctly.
4. Launch phase¶
If sync succeeded (no copy failures):
1. Build the command line: <clientPath>\Pulse Dashboard.exe remote <forwarded args>.
2. Strip launcher-only flags (/clean, /verify, etc.) from the forwarded args.
3. Process.Start the executable.
4. Log the total launcher duration.
The remote argument tells Pulse Dashboard it's being run by the launcher (as opposed to being invoked directly), in case the application wants to behave differently in either case.
Why the log design doesn't affect performance¶
The log file is rewritten on every log entry to maintain newest-first ordering. This sounds expensive but isn't: - The log file is bounded by retention (10 sessions) and size (5MB hard cap). In practice it's typically 10-50KB. - Local disk writes of that size complete in well under a millisecond. - Repeated writes to the same file go through the OS cache; only periodic flushes hit physical disk. - Logging happens between phases, not during tight inner loops.
The log writes contribute negligibly to total launch time — well under 1% on any reasonable machine.
Threading model¶
The sync runs on a background thread while the WinForms message loop on the main thread keeps the splash responsive. The pattern looks like:
Dim worker As New Thread(
Sub()
Try
localResult = updater.UpdateClient(...)
Catch ex As Exception
localException = ex
End Try
End Sub) With {.IsBackground = True}
worker.Start()
While worker.IsAlive
Application.DoEvents()
Thread.Sleep(30)
End While
DoEvents lets the splash redraw and respond to its SetStatus calls. Thread.Sleep(30) keeps the polling loop from spinning the CPU while waiting for the worker.
SetStatus itself is thread-safe: it checks Me.InvokeRequired and marshals to the UI thread via BeginInvoke if needed.
What runs on what thread¶
| Code | Thread |
|---|---|
Main.RunUpdateAndLaunch |
UI thread |
Splash form (MainSplash.SetStatus) |
UI thread, marshalled if necessary |
ClientUpdater.UpdateClient and everything it calls |
Worker thread |
UpdateLogger writes |
Whichever thread calls them — protected by a SyncLock |
MessageBox.Show (failure dialogs) |
UI thread, after worker returns |
The clean strict separation keeps the splash responsive even when the sync takes seconds, and keeps the dialog code on the right thread without needing to mess with Invoke.