Skip to content

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 &gt;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.