v0.15.4: ARCH-1 step 4 — extract MountManager (final ARCH-1 step)#45
v0.15.4: ARCH-1 step 4 — extract MountManager (final ARCH-1 step)#45NathanNeurotic wants to merge 3 commits into
Conversation
Second collaborator extracted from TrayContext partial. No user- visible changes. - New src/SettingsStore.cs owns settings.json read/write/cache/ atomic-write/backup/prune mechanics. Constructor takes the settings file path + log callbacks. - TrayContext.Settings.cs delegates ReadSettingsRoot, WriteSettingsRoot, BackupSettingsFile, BackupsDir to a lazy SettingsStore field. PERF-3 cache behavior preserved. - Profile-specific shape logic (LoadProfiles/SaveProfiles) stays in TrayContext as before. 53/53 tests green, FileVersion 0.15.2.0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third collaborator extracted from TrayContext. No user-visible changes. - New src/DependencyProbe.cs owns cached RcloneAvailable / WinFspInstalled state, TTL stamp, and the slow synchronous probes (File.Exists, spawn rclone version, registry lookups). Constructor takes Func<string> rclone path provider + log callback. - TrayContext.Setup.cs delegates RcloneAvailable, WinFspInstalled, ProbeRcloneAvailableSync, ProbeWinFspInstalledSync. The async refresh worker stays in TrayContext (composes results into the setup-status line) and calls Deps.PublishProbeResults at the end. 53/53 tests green, FileVersion 0.15.3.0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth and final ARCH-1 collaborator extraction. No user-visible
changes.
- New src/MountManager.cs owns:
* BuildMountArgs(profile, fullCache, rcCommonFlags, bandwidth)
pure helper that assembles the full rclone mount argv with
all the v0.5.x cache-mode/VFS/network-mode/RC/log/bandwidth
flags. Unit-testable without disk.
* StartMountProcess(rclonePath, args, logJobWarn) Process.Start
+ v0.11.4 Job Object binding.
- TrayContext.Mount.cs delegates argv assembly and the spawn. The
dialog flow (rclone-missing, WinFsp-missing, remote-not-
configured, drive-in-use, post-launch result) stays in
TrayContext because it's UI-coupled.
53/53 tests green, FileVersion 0.15.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request refactors the TrayContext class by extracting several responsibilities into dedicated helper classes: SettingsStore for settings management, DependencyProbe for dependency availability checks, and MountManager for rclone mount process management. The review feedback highlights several critical thread-safety issues introduced during this refactoring, including non-thread-safe lazy initialization of the new helper classes, potential torn reads of a 64-bit DateTime field on 32-bit architectures, and unsafe concurrent access to the cached settings dictionary. Implementing the suggested locking and cloning mechanisms will ensure thread safety across the application.
| public Dictionary<string, object> ReadRoot() | ||
| { | ||
| lock (_cacheLock) | ||
| { | ||
| if (_cache != null) return _cache; | ||
| Dictionary<string, object> root; | ||
| if (TryReadFile(_settingsFile, out root)) | ||
| { | ||
| _cache = root; | ||
| return _cache; | ||
| } | ||
| string backupFile = _settingsFile + ".bak"; | ||
| if (TryReadFile(backupFile, out root)) | ||
| { | ||
| if (_logWarn != null) _logWarn("read settings", "loaded backup settings file after primary file could not be read"); | ||
| _cache = root; | ||
| return _cache; | ||
| } | ||
| _cache = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); | ||
| return _cache; | ||
| } | ||
| } | ||
|
|
||
| public void WriteRoot(Dictionary<string, object> root) | ||
| { | ||
| try | ||
| { | ||
| Directory.CreateDirectory(_settingsDir); | ||
| string json; | ||
| lock (_cacheLock) | ||
| { | ||
| _cache = root; | ||
| JavaScriptSerializer js = new JavaScriptSerializer(); | ||
| json = js.Serialize(root); | ||
| } | ||
| TrayContext.WriteAllTextAtomic(_settingsFile, json, Encoding.UTF8); | ||
| } | ||
| catch (Exception ex) { if (_logIssue != null) _logIssue("write settings", ex); } | ||
| } |
There was a problem hiding this comment.
The ReadRoot method returns the direct reference to the cached _cache dictionary. Because Dictionary<TKey, TValue> is not thread-safe, concurrent reads and writes from different threads (such as the UI thread and background workers calling SaveSetting or SaveProfiles) can corrupt the dictionary's internal structure or throw InvalidOperationException during serialization.
Returning a new copy of the dictionary in ReadRoot and cloning the dictionary in WriteRoot under the _cacheLock ensures that each caller works on their own private copy, completely eliminating concurrent modification issues.
public Dictionary<string, object> ReadRoot()
{
lock (_cacheLock)
{
if (_cache == null)
{
Dictionary<string, object> root;
if (TryReadFile(_settingsFile, out root))
{
_cache = root;
}
else
{
string backupFile = _settingsFile + ".bak";
if (TryReadFile(backupFile, out root))
{
if (_logWarn != null) _logWarn("read settings", "loaded backup settings file after primary file could not be read");
_cache = root;
}
else
{
_cache = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}
}
}
return new Dictionary<string, object>(_cache, StringComparer.OrdinalIgnoreCase);
}
}
public void WriteRoot(Dictionary<string, object> root)
{
try
{
Directory.CreateDirectory(_settingsDir);
string json;
lock (_cacheLock)
{
_cache = root != null ? new Dictionary<string, object>(root, StringComparer.OrdinalIgnoreCase) : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
JavaScriptSerializer js = new JavaScriptSerializer();
json = js.Serialize(_cache);
}
TrayContext.WriteAllTextAtomic(_settingsFile, json, Encoding.UTF8);
}
catch (Exception ex) { if (_logIssue != null) _logIssue("write settings", ex); }
}| private volatile bool _cachedRcloneAvailable; | ||
| private volatile bool _cachedWinfspInstalled; | ||
| private DateTime _cachedStampUtc = DateTime.MinValue; | ||
|
|
||
| public DependencyProbe(Func<string> rclonePathProvider, Action<string, Exception> logIssue) | ||
| { | ||
| _rclonePathProvider = rclonePathProvider; | ||
| _logIssue = logIssue; | ||
| } | ||
|
|
||
| public bool RcloneAvailable { get { return _cachedRcloneAvailable; } } | ||
| public bool WinFspInstalled { get { return _cachedWinfspInstalled; } } | ||
| public DateTime LastProbeUtc { get { return _cachedStampUtc; } } | ||
| public bool IsStale { get { return (DateTime.UtcNow - _cachedStampUtc).TotalSeconds >= CacheTtlSeconds; } } | ||
|
|
||
| // Called by the async refresh worker after the slow probes complete. | ||
| public void PublishProbeResults(bool rcloneAvailable, bool winfspInstalled) | ||
| { | ||
| _cachedRcloneAvailable = rcloneAvailable; | ||
| _cachedWinfspInstalled = winfspInstalled; | ||
| _cachedStampUtc = DateTime.UtcNow; | ||
| } |
There was a problem hiding this comment.
The _cachedStampUtc field is a 64-bit DateTime value that is read and written across different threads (the UI thread and the background refresh worker thread) without synchronization. On 32-bit architectures, 64-bit reads and writes are not atomic, which can lead to torn reads. Additionally, without memory barriers, there is no guarantee of visibility or ordering.
Using a lock to synchronize access to all cached fields (_cachedRcloneAvailable, _cachedWinfspInstalled, and _cachedStampUtc) ensures thread safety and correctness across all platforms.
private readonly object _lock = new object();
private bool _cachedRcloneAvailable;
private bool _cachedWinfspInstalled;
private DateTime _cachedStampUtc = DateTime.MinValue;
public DependencyProbe(Func<string> rclonePathProvider, Action<string, Exception> logIssue)
{
_rclonePathProvider = rclonePathProvider;
_logIssue = logIssue;
}
public bool RcloneAvailable { get { lock (_lock) return _cachedRcloneAvailable; } }
public bool WinFspInstalled { get { lock (_lock) return _cachedWinfspInstalled; } }
public DateTime LastProbeUtc { get { lock (_lock) return _cachedStampUtc; } }
public bool IsStale { get { lock (_lock) return (DateTime.UtcNow - _cachedStampUtc).TotalSeconds >= CacheTtlSeconds; } }
// Called by the async refresh worker after the slow probes complete.
public void PublishProbeResults(bool rcloneAvailable, bool winfspInstalled)
{
lock (_lock)
{
_cachedRcloneAvailable = rcloneAvailable;
_cachedWinfspInstalled = winfspInstalled;
_cachedStampUtc = DateTime.UtcNow;
}
}| private DependencyProbe _depProbe; | ||
| private DependencyProbe Deps | ||
| { | ||
| EnsureDependencyCacheFresh(); | ||
| return cachedRcloneAvailable; | ||
| get | ||
| { | ||
| if (_depProbe == null) | ||
| { | ||
| _depProbe = new DependencyProbe( | ||
| delegate { rclonePath = FindRclonePath(); return rclonePath; }, | ||
| LogUiIssue); | ||
| } | ||
| return _depProbe; | ||
| } | ||
| } |
There was a problem hiding this comment.
The Deps property lazily instantiates _depProbe without synchronization. Since Deps is accessed from both the UI thread and background worker threads (e.g., in MountProfile), a race condition could result in multiple instances of DependencyProbe being created concurrently.
Adding a lock ensures thread-safe lazy initialization.
private readonly object _depProbeLock = new object();
private DependencyProbe _depProbe;
private DependencyProbe Deps
{
get
{
lock (_depProbeLock)
{
if (_depProbe == null)
{
_depProbe = new DependencyProbe(
delegate { rclonePath = FindRclonePath(); return rclonePath; },
LogUiIssue);
}
return _depProbe;
}
}
}| private SettingsStore _settingsStore; | ||
| private SettingsStore SettingsBackend | ||
| { | ||
| lock (settingsRootCacheLock) | ||
| get | ||
| { | ||
| if (settingsRootCache != null) return settingsRootCache; | ||
| Dictionary<string, object> root; | ||
| if (TryReadSettingsRoot(settingsFile, out root)) | ||
| { | ||
| settingsRootCache = root; | ||
| return settingsRootCache; | ||
| } | ||
| string backupFile = settingsFile + ".bak"; | ||
| if (TryReadSettingsRoot(backupFile, out root)) | ||
| if (_settingsStore == null) | ||
| { | ||
| LogUiWarn("read settings", "loaded backup settings file after primary file could not be read"); | ||
| settingsRootCache = root; | ||
| return settingsRootCache; | ||
| _settingsStore = new SettingsStore(settingsFile, LogUiIssue, | ||
| delegate(string area, string msg) { LogUiWarn(area, msg); }); | ||
| } | ||
| settingsRootCache = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); | ||
| return settingsRootCache; | ||
| return _settingsStore; | ||
| } | ||
| } |
There was a problem hiding this comment.
The SettingsBackend property lazily instantiates _settingsStore without synchronization. Since SettingsBackend is accessed from both the UI thread and background worker threads (e.g., in SaveSetting), a race condition could result in multiple instances of SettingsStore being created concurrently.
Adding a lock ensures thread-safe lazy initialization.
private readonly object _settingsStoreLock = new object();
private SettingsStore _settingsStore;
private SettingsStore SettingsBackend
{
get
{
lock (_settingsStoreLock)
{
if (_settingsStore == null)
{
_settingsStore = new SettingsStore(settingsFile, LogUiIssue,
delegate(string area, string msg) { LogUiWarn(area, msg); });
}
return _settingsStore;
}
}
}
Summary
Fourth and final ARCH-1 collaborator extraction. Closes the audit's incremental ARCH-1 work. No user-visible changes. Depends on PRs #43 (v0.15.2 SettingsStore) and #44 (v0.15.3 DependencyProbe).
Changed
ARCH-1 close-out
Across v0.15.1–v0.15.4 we've extracted:
The rest of `TrayContext` is UI orchestration (menu, main window, dialogs, balloons, tray icon, the live-state refresh loop). Further extraction past this point either pulls in UI dependencies or has diminishing returns; the audit's "incremental" intent for ARCH-1 is satisfied.
Test plan
🤖 Generated with Claude Code