From a4920c244c0968f2dc377b86b096f55f4108aace Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:48:45 -0400 Subject: [PATCH 1/9] Port upstream database access race fix --- CompactGUI/Services/WikiService.vb | 117 ++++++++++++++++++----------- 1 file changed, 75 insertions(+), 42 deletions(-) diff --git a/CompactGUI/Services/WikiService.vb b/CompactGUI/Services/WikiService.vb index cb548fe5..24140ac6 100644 --- a/CompactGUI/Services/WikiService.vb +++ b/CompactGUI/Services/WikiService.vb @@ -1,5 +1,6 @@ Imports System.Net.Http Imports System.Text.Json +Imports System.Threading Imports CompactGUI.Core.Settings @@ -17,6 +18,7 @@ Public Class WikiService : Implements IWikiService Private ReadOnly dlPath As String Private ReadOnly _settingsService As ISettingsService + Private ReadOnly _databaseGate As New SemaphoreSlim(1, 1) Public Sub New(settingsService As ISettingsService) _settingsService = settingsService @@ -30,47 +32,84 @@ Public Class WikiService : Implements IWikiService Debug.WriteLine("Updating JSON file") Dim JSONFile As New IO.FileInfo(filePath) - If JSONFile.Exists AndAlso _settingsService.AppSettings.ResultsDBLastUpdated.AddHours(6) >= DateTime.Now Then Return - - Dim httpClient As New HttpClient - + Await _databaseGate.WaitAsync().ConfigureAwait(False) Try - - Dim res = Await httpClient.GetStreamAsync(dlPath) - - Using fs As New IO.FileStream(JSONFile.FullName, IO.FileMode.Create) - Await res.CopyToAsync(fs) + If JSONFile.Exists AndAlso + _settingsService.AppSettings.ResultsDBLastUpdated.AddHours(6) >= DateTime.Now AndAlso + Await IsDatabaseJsonValidAsync(JSONFile.FullName).ConfigureAwait(False) Then + Return + End If + + Using httpClient As New HttpClient() + Using responseStream = Await httpClient.GetStreamAsync(dlPath).ConfigureAwait(False) + Using fs As New IO.FileStream(JSONFile.FullName, IO.FileMode.Create) + Await responseStream.CopyToAsync(fs).ConfigureAwait(False) + End Using + End Using End Using - Catch ex As TaskCanceledException - Debug.WriteLine("HTTP request timed out.") - Return - - Catch ex As IO.IOException - Debug.WriteLine("Could not update JSON file: file is in use.") - Return - Catch ex As HttpRequestException - Debug.WriteLine($"Unable to reach endpoint. Likely no internet connection") - Return + If Not Await IsDatabaseJsonValidAsync(JSONFile.FullName).ConfigureAwait(False) Then + Debug.WriteLine("Downloaded database JSON is invalid.") + IO.File.Delete(JSONFile.FullName) + Return + End If + + _settingsService.AppSettings.ResultsDBLastUpdated = DateTime.Now + _settingsService.SaveSettings() + Debug.WriteLine("Updated JSON file") + + Catch ex As Exception When TypeOf ex Is TaskCanceledException OrElse + TypeOf ex Is IO.IOException OrElse + TypeOf ex Is HttpRequestException OrElse + TypeOf ex Is UnauthorizedAccessException + Debug.WriteLine($"Could not update database JSON: {ex.Message}") Finally - httpClient.Dispose() + _databaseGate.Release() End Try + End Function - _settingsService.AppSettings.ResultsDBLastUpdated = DateTime.Now - _settingsService.SaveSettings() - Debug.WriteLine("Updated JSON file") + Private Async Function IsDatabaseJsonValidAsync(path As String) As Task(Of Boolean) + If Not IO.File.Exists(path) Then Return False + Try + Using stream = IO.File.OpenRead(path) + Using document = Await JsonDocument.ParseAsync(stream).ConfigureAwait(False) + Return document.RootElement.ValueKind = JsonValueKind.Array + End Using + End Using + Catch ex As Exception When TypeOf ex Is JsonException OrElse + TypeOf ex Is IO.IOException OrElse + TypeOf ex Is UnauthorizedAccessException + Debug.WriteLine($"Database JSON is invalid or unreadable: {ex.Message}") + Return False + End Try End Function Private ReadOnly JsonDefaultSettings As New JsonSerializerOptions With {.IncludeFields = True} + Private Async Function ReadDatabaseAsync() As Task(Of List(Of SteamResultsData)) + Await _databaseGate.WaitAsync().ConfigureAwait(False) + Try + If Not IO.File.Exists(filePath) Then Return Nothing + + Using stream = IO.File.OpenRead(filePath) + Return Await JsonSerializer.DeserializeAsync(Of List(Of SteamResultsData))(stream, JsonDefaultSettings).ConfigureAwait(False) + End Using + Catch ex As Exception When TypeOf ex Is JsonException OrElse + TypeOf ex Is IO.IOException OrElse + TypeOf ex Is UnauthorizedAccessException + Debug.WriteLine($"Database JSON is invalid or unreadable: {ex.Message}") + Return Nothing + Finally + _databaseGate.Release() + End Try + End Function + Async Function ParseData(appid As Integer) As Task(Of (estimatedRatio As Decimal, confidence As Integer, poorlyCompressedList As Dictionary(Of String, Integer), compressionResults As List(Of CompressionResult))) Implements IWikiService.ParseData - Dim JSONFile As New IO.FileInfo(filePath) - If Not JSONFile.Exists Then Return Nothing + Dim parsedSteamWikiResults = Await ReadDatabaseAsync().ConfigureAwait(False) + If parsedSteamWikiResults Is Nothing Then Return Nothing - Dim jStream As IO.FileStream = JSONFile.OpenRead - Dim parsedSteamWikiResults = Await JsonSerializer.DeserializeAsync(Of List(Of SteamResultsData))(jStream, JsonDefaultSettings).ConfigureAwait(False) Dim workingGame = parsedSteamWikiResults.Find(Function(game) game.SteamID = appid) If workingGame Is Nothing Then Return Nothing @@ -94,18 +133,13 @@ Public Class WikiService : Implements IWikiService Public Async Function GetAllDatabaseCompressionResultsAsync() As Task(Of List(Of DatabaseCompressionResult)) Implements IWikiService.GetAllDatabaseCompressionResultsAsync - Dim JSONFile As New IO.FileInfo(filePath) - If Not JSONFile.Exists Then Return New List(Of DatabaseCompressionResult)() + Dim parsedResults = Await ReadDatabaseAsync().ConfigureAwait(False) + If parsedResults Is Nothing Then Return New List(Of DatabaseCompressionResult)() - Using jStream As IO.FileStream = JSONFile.OpenRead() - ' Deserialize the JSON into a list of SteamResultsData (or your source model) - Dim parsedResults = Await JsonSerializer.DeserializeAsync(Of List(Of SteamResultsData))(jStream, JsonDefaultSettings).ConfigureAwait(False) - If parsedResults Is Nothing Then Return New List(Of DatabaseCompressionResult)() - - ' Map each SteamResultsData to DatabaseCompressionResult - Dim results As New List(Of DatabaseCompressionResult) - For Each item In parsedResults - Dim dbResult As New DatabaseCompressionResult With { + ' Map each SteamResultsData to DatabaseCompressionResult + Dim results As New List(Of DatabaseCompressionResult) + For Each item In parsedResults + Dim dbResult As New DatabaseCompressionResult With { .GameName = item.GameName, .SteamID = item.SteamID, .Confidence = CType(item.Confidence, DBResultConfidence), @@ -115,11 +149,10 @@ Public Class WikiService : Implements IWikiService .Result_LZX = item.CompressionResults.FirstOrDefault(Function(r) r.CompType = 3), .PoorlyCompressedExtensions = item.PoorlyCompressedExtensions?.Select(Function(kvp) New DBPoorlyCompressedExtension With {.Extension = kvp.Key, .Count = kvp.Value}).ToList() } - results.Add(dbResult) - Next + results.Add(dbResult) + Next - Return results - End Using + Return results End Function From 71973708922965cbda5a725349fb826ea0da10f4 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:49:13 -0400 Subject: [PATCH 2/9] Port upstream cancellation token lifetime fixes --- .../Services/CompressableFolderService.vb | 146 ++++++++++-------- 1 file changed, 82 insertions(+), 64 deletions(-) diff --git a/CompactGUI/Services/CompressableFolderService.vb b/CompactGUI/Services/CompressableFolderService.vb index eaed5dbd..fda557c0 100644 --- a/CompactGUI/Services/CompressableFolderService.vb +++ b/CompactGUI/Services/CompressableFolderService.vb @@ -168,34 +168,37 @@ Public Class CompressableFolderService folderTokens(folder) = cts Dim token = cts.Token + Try + folder.Analyser?.Dispose() + folder.Analyser = New Analyser(folder.FolderName, AnalyserLogger) - folder.Analyser?.Dispose() - folder.Analyser = New Analyser(folder.FolderName, AnalyserLogger) - - If Not Core.SharedMethods.HasDirectoryWritePermission(folder.FolderName) Then - folder.FolderActionState = ActionState.Idle - Return -1 - End If + If Not Core.SharedMethods.HasDirectoryWritePermission(folder.FolderName) Then + folder.FolderActionState = ActionState.Idle + Return -1 + End If - Dim retAnalysisResults = Await folder.Analyser.GetAnalysedFilesAsync(token) - If cts.IsCancellationRequested Then - folder.FolderActionState = ActionState.Idle - Return 1 - End If + Dim retAnalysisResults = Await folder.Analyser.GetAnalysedFilesAsync(token) + If cts.IsCancellationRequested Then + folder.FolderActionState = ActionState.Idle + Return 1 + End If - folder.AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)(retAnalysisResults) - folder.UncompressedBytes = folder.Analyser.UncompressedBytes - folder.CompressedBytes = folder.Analyser.CompressedBytes - folder.IsDirectStorage = folder.Analyser.IsDirectStorage + folder.AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)(retAnalysisResults) + folder.UncompressedBytes = folder.Analyser.UncompressedBytes + folder.CompressedBytes = folder.Analyser.CompressedBytes + folder.IsDirectStorage = folder.Analyser.IsDirectStorage - If folder.Analyser.ContainsCompressedFiles OrElse folder.IsFreshlyCompressed Then - folder.FolderActionState = ActionState.Results - Else - folder.FolderActionState = ActionState.Idle - End If - folder.PoorlyCompressedFiles = folder.Analyser.GetPoorlyCompressedExtensions() + If folder.Analyser.ContainsCompressedFiles OrElse folder.IsFreshlyCompressed Then + folder.FolderActionState = ActionState.Results + Else + folder.FolderActionState = ActionState.Idle + End If + folder.PoorlyCompressedFiles = folder.Analyser.GetPoorlyCompressedExtensions() - Return 0 + Return 0 + Finally + ReleaseToken(folder, cts) + End Try End Function @@ -206,60 +209,64 @@ Public Class CompressableFolderService Dim cts = New CancellationTokenSource() folderTokens(folder) = cts - Dim estimator As New Estimator - Dim estimatedData As List(Of (AnalysedFile As AnalysedFileDetails, CompressionRatio As Single)) = Nothing - Try - estimatedData = Await Task.Run(Function() estimator.EstimateCompression(folder.AnalysisResults.ToList, IsHDD(folder), GetThreadCount(folder), Core.SharedMethods.GetClusterSize(folder.FolderName), cts.Token)) + Dim estimator As New Estimator + Dim estimatedData As List(Of (AnalysedFile As AnalysedFileDetails, CompressionRatio As Single)) = Nothing - Catch ex As AggregateException - folder.IsGettingEstimate = False - Return - End Try + Try + estimatedData = Await Task.Run(Function() estimator.EstimateCompression(folder.AnalysisResults.ToList, IsHDD(folder), GetThreadCount(folder), Core.SharedMethods.GetClusterSize(folder.FolderName), cts.Token)) - For Each item In estimatedData - If item.CompressionRatio >= 0.98 AndAlso item.AnalysedFile.FileName <> "" Then - folder.WikiPoorlyCompressedFiles.Add(item.AnalysedFile.FileName) - End If - Next + Catch ex As AggregateException + folder.IsGettingEstimate = False + Return + End Try - Dim estimatedAfterBytes = estimatedData.Sum(Function(x) x.AnalysedFile.UncompressedSize * x.CompressionRatio) + For Each item In estimatedData + If item.CompressionRatio >= 0.98 AndAlso item.AnalysedFile.FileName <> "" Then + folder.WikiPoorlyCompressedFiles.Add(item.AnalysedFile.FileName) + End If + Next - 'This is absolutely stupid + Dim estimatedAfterBytes = estimatedData.Sum(Function(x) x.AnalysedFile.UncompressedSize * x.CompressionRatio) - Dim X4KResult As New CompressionResult - X4KResult.CompType = CompressionMode.XPRESS4K - X4KResult.BeforeBytes = folder.UncompressedBytes - X4KResult.AfterBytes = Math.Min(estimatedAfterBytes * 1.01, folder.UncompressedBytes) - X4KResult.TotalResults = 1 + 'This is absolutely stupid - Dim X8KResult As New CompressionResult - X8KResult.CompType = CompressionMode.XPRESS8K - X8KResult.BeforeBytes = folder.UncompressedBytes - X8KResult.AfterBytes = Math.Min(estimatedAfterBytes * 1.0, folder.UncompressedBytes) - X8KResult.TotalResults = 1 + Dim X4KResult As New CompressionResult + X4KResult.CompType = CompressionMode.XPRESS4K + X4KResult.BeforeBytes = folder.UncompressedBytes + X4KResult.AfterBytes = Math.Min(estimatedAfterBytes * 1.01, folder.UncompressedBytes) + X4KResult.TotalResults = 1 - Dim X16KResult As New CompressionResult - X16KResult.CompType = CompressionMode.XPRESS16K - X16KResult.BeforeBytes = folder.UncompressedBytes - X16KResult.AfterBytes = Math.Min(estimatedAfterBytes * 0.98, folder.UncompressedBytes) - X16KResult.TotalResults = 1 + Dim X8KResult As New CompressionResult + X8KResult.CompType = CompressionMode.XPRESS8K + X8KResult.BeforeBytes = folder.UncompressedBytes + X8KResult.AfterBytes = Math.Min(estimatedAfterBytes * 1.0, folder.UncompressedBytes) + X8KResult.TotalResults = 1 - Dim LZXResult As New CompressionResult - LZXResult.CompType = CompressionMode.LZX - LZXResult.BeforeBytes = folder.UncompressedBytes - LZXResult.AfterBytes = Math.Min(estimatedAfterBytes * 0.95, folder.UncompressedBytes) - LZXResult.TotalResults = 1 + Dim X16KResult As New CompressionResult + X16KResult.CompType = CompressionMode.XPRESS16K + X16KResult.BeforeBytes = folder.UncompressedBytes + X16KResult.AfterBytes = Math.Min(estimatedAfterBytes * 0.98, folder.UncompressedBytes) + X16KResult.TotalResults = 1 - folder.WikiCompressionResults = New WikiCompressionResults(New List(Of CompressionResult) From {X4KResult, X8KResult, X16KResult, LZXResult}) + Dim LZXResult As New CompressionResult + LZXResult.CompType = CompressionMode.LZX + LZXResult.BeforeBytes = folder.UncompressedBytes + LZXResult.AfterBytes = Math.Min(estimatedAfterBytes * 0.95, folder.UncompressedBytes) + LZXResult.TotalResults = 1 - folder.IsGettingEstimate = False + folder.WikiCompressionResults = New WikiCompressionResults(New List(Of CompressionResult) From {X4KResult, X8KResult, X16KResult, LZXResult}) + folder.IsGettingEstimate = False - folder.NotifyPropertyChanged(NameOf(folder.WikiCompressionResults)) - folder.NotifyPropertyChanged(NameOf(folder.WikiPoorlyCompressedFiles)) - folder.NotifyPropertyChanged(NameOf(folder.WikiPoorlyCompressedFilesCount)) - folder.NotifyPropertyChanged(NameOf(folder.IsGettingEstimate)) + + folder.NotifyPropertyChanged(NameOf(folder.WikiCompressionResults)) + folder.NotifyPropertyChanged(NameOf(folder.WikiPoorlyCompressedFiles)) + folder.NotifyPropertyChanged(NameOf(folder.WikiPoorlyCompressedFilesCount)) + folder.NotifyPropertyChanged(NameOf(folder.IsGettingEstimate)) + Finally + ReleaseToken(folder, cts) + End Try End Function Public Sub CancelEstimation(folder As CompressableFolder) @@ -318,5 +325,16 @@ Public Class CompressableFolderService Return exclist End Function + Private Sub ReleaseToken(folder As CompressableFolder, cts As CancellationTokenSource) + Dim current As CancellationTokenSource = Nothing + + If folderTokens.TryGetValue(folder, current) AndAlso + Object.ReferenceEquals(current, cts) Then + folderTokens.Remove(folder) + End If + + cts.Dispose() + End Sub + End Class From b10c3d8ec39a0eb3866d6fdb7febdd94172c9250 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:49:45 -0400 Subject: [PATCH 3/9] Unsubscribe folder view models from app settings --- CompactGUI/ViewModels/FolderViewModel.vb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb index ab243d84..c32f0e41 100644 --- a/CompactGUI/ViewModels/FolderViewModel.vb +++ b/CompactGUI/ViewModels/FolderViewModel.vb @@ -52,20 +52,22 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem Private ReadOnly _snackbarService As CustomSnackBarService Private ReadOnly _compressableFolderService As CompressableFolderService Private ReadOnly _compressionFilesByPath As New Dictionary(Of String, CompressionFileProgressItem)(StringComparer.OrdinalIgnoreCase) + Private ReadOnly _appSettings As Core.Settings.Settings Public Sub New(folder As CompressableFolder, watcher As Watcher.Watcher, snackbarService As CustomSnackBarService, compressableFolderService As CompressableFolderService) Me.Folder = folder _watcher = watcher _snackbarService = snackbarService _compressableFolderService = compressableFolderService + _appSettings = Application.GetService(Of Core.Settings.ISettingsService)().AppSettings AddHandler folder.PropertyChanged, AddressOf OnFolderPropertyChanged AddHandler folder.CompressionOptions.PropertyChanged, AddressOf OnFolderCompressionOptionsPropertyChanged - AddHandler Application.GetService(Of Core.Settings.ISettingsService).AppSettings.PropertyChanged, AddressOf OnAppSettingsPropertyChanged + AddHandler _appSettings.PropertyChanged, AddressOf OnAppSettingsPropertyChanged End Sub Private Sub OnAppSettingsPropertyChanged(sender As Object, e As PropertyChangedEventArgs) If e.PropertyName Is NameOf(Core.Settings.Settings.AlwaysShowDetailedCompressionMode) Then - AlwaysShowDetailsCompressionMode = Application.GetService(Of Core.Settings.ISettingsService).AppSettings.AlwaysShowDetailedCompressionMode + AlwaysShowDetailsCompressionMode = _appSettings.AlwaysShowDetailedCompressionMode ElseIf e.PropertyName = NameOf(Core.Settings.Settings.NonCompressableList) Then Folder.NotifyPropertyChanged(NameOf(CompressableFolder.SkippedFileCount)) End If @@ -368,6 +370,7 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem Public Sub Dispose() Implements IDisposable.Dispose RemoveHandler Folder.PropertyChanged, AddressOf OnFolderPropertyChanged RemoveHandler Folder.CompressionOptions.PropertyChanged, AddressOf OnFolderCompressionOptionsPropertyChanged + RemoveHandler _appSettings.PropertyChanged, AddressOf OnAppSettingsPropertyChanged End Sub From 1c9ec63d8ad696b3ae6cebe87312d84315b792f3 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:50:26 -0400 Subject: [PATCH 4/9] Fix folder removal lifetime and selection handling --- CompactGUI/ViewModels/HomeViewModel.vb | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index 7f98e5f5..3b3125b1 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -211,26 +211,34 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Public Sub RemoveFolder(folder As CompressableFolder) If Not CanRemoveFolder() Then - Application.GetService(Of CustomSnackBarService)().ShowCannotRemoveFolder() + _snackbarService.ShowCannotRemoveFolder() Return End If If folder Is Nothing Then Return + Dim index = Folders.IndexOf(folder) - _compressableFolderService.CancelEstimation(folder) - folder.Dispose() + Dim wasSelected = Object.ReferenceEquals(SelectedFolder, folder) - Dim value As FolderViewModel = Nothing + _compressableFolderService.CancelEstimation(folder) - If _folderViewModels.TryGetValue(folder, value) Then - value.Dispose() + Dim folderViewModel As FolderViewModel = Nothing + If _folderViewModels.TryGetValue(folder, folderViewModel) Then + folderViewModel.Dispose() _folderViewModels.Remove(folder) End If Folders.Remove(folder) - If SelectedFolder IsNot Nothing OrElse Folders.Count = 0 Then Return - SelectedFolder = If(index < Folders.Count, Folders(index), Folders.Last()) + If wasSelected Then + If Folders.Count = 0 Then + SelectedFolder = Nothing + Else + SelectedFolder = If(index < Folders.Count, Folders(index), Folders.Last()) + End If + End If + + folder.Dispose() End Sub Public Function CanRemoveFolder() As Boolean @@ -449,4 +457,4 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Application.GetService(Of CustomSnackBarService).ShowAddedToQueue() Await AddFoldersAsync({message.Value}) End Sub -End Class +End Class \ No newline at end of file From 4e6eaf71630a29e1b17553e0eea7115ef28160b0 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:51:13 -0400 Subject: [PATCH 5/9] Fix watcher background mode change messaging --- CompactGUI.Watcher/Watcher.vb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CompactGUI.Watcher/Watcher.vb b/CompactGUI.Watcher/Watcher.vb index ac448f56..9d85f245 100644 --- a/CompactGUI.Watcher/Watcher.vb +++ b/CompactGUI.Watcher/Watcher.vb @@ -20,7 +20,7 @@ Imports Microsoft.Win32 Imports Microsoft.Win32.Registry -Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipient(Of PropertyChangedMessage(Of Boolean)) +Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipient(Of PropertyChangedMessage(Of Boolean)), IRecipient(Of PropertyChangedMessage(Of BackgroundMode)) Private ReadOnly _DataFolder As IO.DirectoryInfo Private ReadOnly _parseWatchersSemaphore As New SemaphoreSlim(1, 1) @@ -533,13 +533,15 @@ Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipi End Function Public Sub Receive(message As PropertyChangedMessage(Of Boolean)) Implements IRecipient(Of PropertyChangedMessage(Of Boolean)).Receive - If (message.Sender.GetType() IsNot GetType(Settings)) Then Return + If message.Sender.GetType() IsNot GetType(Settings) Then Return - If message.PropertyName = NameOf(Settings.EnableBackgroundWatcher) Then : IsWatchingEnabled = message.NewValue - ElseIf message.PropertyName = NameOf(Settings.BackgroundModeSelection) Then : IsBackgroundCompactingEnabled = (CType(message.NewValue, BackgroundMode) = BackgroundMode.IdleOnly) - End If + If message.PropertyName = NameOf(Settings.EnableBackgroundWatcher) Then IsWatchingEnabled = message.NewValue + End Sub + Public Sub Receive(message As PropertyChangedMessage(Of BackgroundMode)) Implements IRecipient(Of PropertyChangedMessage(Of BackgroundMode)).Receive + If message.Sender.GetType() IsNot GetType(Settings) Then Return + If message.PropertyName = NameOf(Settings.BackgroundModeSelection) Then IsBackgroundCompactingEnabled = message.NewValue = BackgroundMode.IdleOnly End Sub From 6028aed958bae785f37acbe680612b334f4bc41d Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:51:44 -0400 Subject: [PATCH 6/9] Carry watcher pause state into newly created compactors --- CompactGUI.Watcher/BackgroundCompactor.vb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CompactGUI.Watcher/BackgroundCompactor.vb b/CompactGUI.Watcher/BackgroundCompactor.vb index 312faf80..ca1897f4 100644 --- a/CompactGUI.Watcher/BackgroundCompactor.vb +++ b/CompactGUI.Watcher/BackgroundCompactor.vb @@ -96,8 +96,12 @@ Public Class BackgroundCompactor compactor = CreateCompactor(folder.Folder, folder.CompressionLevel, folderSkipList) If compactor Is Nothing Then Return False + 'Pause can arrive after the background run starts but before this folder's + 'native compactor exists. Publish the compactor and inherit the current pause + 'state atomically so a newly-created compactor cannot run while the user is active. SyncLock _compactorLock _compactor = compactor + If isCompactingPaused Then compactor.Pause() End SyncLock Dim compactingTask = compactor.RunAsync(Nothing) From 847a7e0cbf0fabcd7b8d93ff71fcc5656d55318e Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:52:05 -0400 Subject: [PATCH 7/9] Release analyser cache backing storage on dispose --- CompactGUI.Core/Analyser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CompactGUI.Core/Analyser.cs b/CompactGUI.Core/Analyser.cs index 01f851f6..d6c74c87 100644 --- a/CompactGUI.Core/Analyser.cs +++ b/CompactGUI.Core/Analyser.cs @@ -157,6 +157,6 @@ public List GetPoorlyCompressedExtensions() public void Dispose() { _folderMonitor.Dispose(); - _analysedFileDetails?.Clear(); + _analysedFileDetails = null; } } \ No newline at end of file From 40655fe5a29cf6c48c361f2a519f9b626136af2e Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:53:19 -0400 Subject: [PATCH 8/9] Release disposed folder resources --- .../Models/CompressableFolders/CompressableFolder.vb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb index d76c9dac..e36ef732 100644 --- a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb +++ b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb @@ -123,12 +123,16 @@ Public MustInherit Class CompressableFolder : Inherits ObservableObject : Implem Public Sub Dispose() Implements IDisposable.Dispose Compressor?.Dispose() - Analyser?.Dispose() + Compressor = Nothing - AnalysisResults?.Clear() - PoorlyCompressedFiles?.Clear() - WikiPoorlyCompressedFiles?.Clear() + Analyser?.Dispose() + Analyser = Nothing + AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)() + PoorlyCompressedFiles = Nothing + WikiPoorlyCompressedFiles = New List(Of String)() + WikiCompressionResults = Nothing + FolderBGImage = Nothing GC.SuppressFinalize(Me) End Sub From a9a2d844fe29341ad91f2a4c4a8f46894a138af1 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:55:08 -0400 Subject: [PATCH 9/9] Port background compactor resource ownership fixes --- CompactGUI.Watcher/BackgroundCompactor.vb | 41 +++++++++++++++-------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/CompactGUI.Watcher/BackgroundCompactor.vb b/CompactGUI.Watcher/BackgroundCompactor.vb index ca1897f4..28c92906 100644 --- a/CompactGUI.Watcher/BackgroundCompactor.vb +++ b/CompactGUI.Watcher/BackgroundCompactor.vb @@ -51,12 +51,15 @@ Public Class BackgroundCompactor Private Function CreateCompactor(folder As String, compressionLevel As Core.WOFCompressionAlgorithm, + ByRef analyser As Core.Analyser, Optional excludedFileTypes As String() = Nothing) As Core.Compactor + analyser = Nothing If compressionLevel = Core.WOFCompressionAlgorithm.NO_COMPRESSION Then Return Nothing Dim effectiveExclusions = If(excludedFileTypes Is Nothing, _excludedFileTypes, excludedFileTypes) - Return New Core.Compactor(folder, compressionLevel, effectiveExclusions, New Core.Analyser(folder, NullLogger(Of Core.Analyser).Instance)) + analyser = New Core.Analyser(folder, NullLogger(Of Core.Analyser).Instance) + Return New Core.Compactor(folder, compressionLevel, effectiveExclusions, analyser) End Function @@ -74,8 +77,10 @@ Public Class BackgroundCompactor isCompactingPaused = False Dim currentProcess As Process = Process.GetCurrentProcess() + Dim originalPriority As ProcessPriorityClass = ProcessPriorityClass.Normal Try + originalPriority = currentProcess.PriorityClass currentProcess.PriorityClass = ProcessPriorityClass.Idle For Each folder In folders.ToList @@ -88,12 +93,13 @@ Public Class BackgroundCompactor folder.IsWorking = True Dim compactor As Core.Compactor = Nothing - Dim disposeCompactor As Boolean = True + Dim compactorAnalyser As Core.Analyser = Nothing + Dim disposeResources As Boolean = True Try WatcherLog.CompactingFolder(_logger, folder.DisplayName) Dim folderSkipList As String() = If(folder.SkipList Is Nothing, Nothing, folder.SkipList.ToArray()) - compactor = CreateCompactor(folder.Folder, folder.CompressionLevel, folderSkipList) + compactor = CreateCompactor(folder.Folder, folder.CompressionLevel, compactorAnalyser, folderSkipList) If compactor Is Nothing Then Return False 'Pause can arrive after the background run starts but before this folder's @@ -111,11 +117,11 @@ Public Class BackgroundCompactor compactor.Cancel() End If - Dim waitResult = Await WaitForCompactorAsync(compactor, compactingTask, folder, runCancellation.Token) + Dim waitResult = Await WaitForCompactorAsync(compactor, compactorAnalyser, compactingTask, folder, runCancellation.Token) If Not waitResult.TaskCompleted Then 'The native operation did not return after cancellation. Its task now owns - 'the compactor lifetime and will dispose it when Windows finally returns. - disposeCompactor = False + 'the compactor/analyser lifetime and will dispose them when Windows finally returns. + disposeResources = False Return False End If @@ -153,8 +159,9 @@ Public Class BackgroundCompactor End If End SyncLock - If disposeCompactor Then + If disposeResources Then compactor?.Dispose() + compactorAnalyser?.Dispose() End If End Try Next @@ -165,8 +172,8 @@ Public Class BackgroundCompactor Trace.WriteLine("Compacting cancelled by user.") Return False Finally - 'Each folder task owns its compactor lifetime. A task detached after a stuck - 'native call disposes its compactor only after that task actually exits. + 'Each folder task owns its compactor/analyser lifetime. A task detached after a stuck + 'native call disposes both only after that task actually exits. isCompacting = False isCompactingPaused = False IsCompactorActive = False @@ -181,14 +188,17 @@ Public Class BackgroundCompactor runCancellation.Dispose() Try - currentProcess.PriorityClass = ProcessPriorityClass.Normal + currentProcess.PriorityClass = originalPriority Catch ex As Exception _logger.LogDebug(ex, "Unable to restore CompactGUI process priority.") + Finally + currentProcess.Dispose() End Try End Try End Function Private Async Function WaitForCompactorAsync(compactor As Core.Compactor, + compactorAnalyser As Core.Analyser, compactingTask As Task(Of Boolean), folder As WatchedFolder, cancellationToken As CancellationToken) As Task(Of (TaskCompleted As Boolean, Result As Boolean)) @@ -201,7 +211,7 @@ Public Class BackgroundCompactor End If If cancellationToken.IsCancellationRequested Then - Return Await StopOrDetachCompactorAsync(compactor, compactingTask, folder, "user cancellation") + Return Await StopOrDetachCompactorAsync(compactor, compactorAnalyser, compactingTask, folder, "user cancellation") End If Await Task.Delay(WatchdogPollInterval) @@ -211,7 +221,7 @@ Public Class BackgroundCompactor End If If cancellationToken.IsCancellationRequested Then - Return Await StopOrDetachCompactorAsync(compactor, compactingTask, folder, "user cancellation") + Return Await StopOrDetachCompactorAsync(compactor, compactorAnalyser, compactingTask, folder, "user cancellation") End If 'A background run can legitimately remain paused while the user is active. @@ -238,12 +248,13 @@ Public Class BackgroundCompactor compactor.CurrentPhase, If(compactor.CurrentFile, "")) - Return Await StopOrDetachCompactorAsync(compactor, compactingTask, folder, "watchdog timeout") + Return Await StopOrDetachCompactorAsync(compactor, compactorAnalyser, compactingTask, folder, "watchdog timeout") End If Loop End Function Private Async Function StopOrDetachCompactorAsync(compactor As Core.Compactor, + compactorAnalyser As Core.Analyser, compactingTask As Task(Of Boolean), folder As WatchedFolder, reason As String) As Task(Of (TaskCompleted As Boolean, Result As Boolean)) @@ -266,13 +277,14 @@ Public Class BackgroundCompactor compactor.CurrentPhase, If(compactor.CurrentFile, "")) - RegisterDetachedCompaction(folder.Folder, folder.DisplayName, compactor, compactingTask) + RegisterDetachedCompaction(folder.Folder, folder.DisplayName, compactor, compactorAnalyser, compactingTask) Return (TaskCompleted:=False, Result:=False) End Function Private Sub RegisterDetachedCompaction(folderPath As String, displayName As String, compactor As Core.Compactor, + compactorAnalyser As Core.Analyser, compactingTask As Task(Of Boolean)) If Not _detachedCompactions.TryAdd(folderPath, compactingTask) Then Return @@ -286,6 +298,7 @@ Public Class BackgroundCompactor End If Finally compactor.Dispose() + compactorAnalyser?.Dispose() Dim removedTask As Task(Of Boolean) = Nothing _detachedCompactions.TryRemove(folderPath, removedTask) End Try