Skip to content

v0.15.4: ARCH-1 step 4 — extract MountManager (final ARCH-1 step)#45

Closed
NathanNeurotic wants to merge 3 commits into
mainfrom
feature/v0.15.4-mount-manager
Closed

v0.15.4: ARCH-1 step 4 — extract MountManager (final ARCH-1 step)#45
NathanNeurotic wants to merge 3 commits into
mainfrom
feature/v0.15.4-mount-manager

Conversation

@NathanNeurotic
Copy link
Copy Markdown
Owner

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

  • New `src/MountManager.cs` owns the rclone mount process lifecycle:
    • `BuildMountArgs(profile, fullCache, rcCommonFlags, effectiveBandwidth)` — pure helper that assembles the full `rclone mount` argv with all the v0.5.x cache-mode / VFS / network-mode / RC / log / bandwidth flags in one place. Unit-testable without disk.
    • `StartMountProcess(rclonePath, args, logJobWarn)` — `Process.Start` plus the v0.11.4 Job Object binding so the rclone child still dies when Pixelpipe dies.
  • `TrayContext.Mount.cs` delegates argv assembly and the spawn to `MountManager`. The dialog flow (rclone-missing, WinFsp-missing, remote-not-configured, drive-in-use, post-launch result) stays in `TrayContext` because it's UI-coupled.

ARCH-1 close-out

Across v0.15.1–v0.15.4 we've extracted:

Step Class Owns
v0.15.1 `RcloneClient` rclone process invocation + `ProcessResult`
v0.15.2 `SettingsStore` `settings.json` read/write/cache/atomic-write/backup
v0.15.3 `DependencyProbe` cached rclone/WinFsp availability + sync probes
v0.15.4 `MountManager` mount argv assembly + Process.Start + Job Object binding

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

  • `scripts/run-tests.ps1` — 53/53 green
  • FileVersion 0.15.4.0
  • Real-world: mount a profile (any provider) — argv built identically, Job Object binding still triggers (kill Pixelpipe.exe in Task Manager and verify rclone.exe dies)

🤖 Generated with Claude Code

NathanNeurotic and others added 3 commits May 28, 2026 23:52
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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/SettingsStore.cs
Comment on lines +46 to +84
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); }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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); }
        }

Comment thread src/DependencyProbe.cs
Comment on lines +23 to +44
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
            }
        }

Comment thread src/Pixelpipe.Setup.cs
Comment on lines +50 to 63
private DependencyProbe _depProbe;
private DependencyProbe Deps
{
EnsureDependencyCacheFresh();
return cachedRcloneAvailable;
get
{
if (_depProbe == null)
{
_depProbe = new DependencyProbe(
delegate { rclonePath = FindRclonePath(); return rclonePath; },
LogUiIssue);
}
return _depProbe;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
                }
            }
        }

Comment thread src/Pixelpipe.Settings.cs
Comment on lines +116 to 128
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;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
                }
            }
        }

@NathanNeurotic NathanNeurotic deleted the feature/v0.15.4-mount-manager branch May 29, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant