Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ That paragraph is not decoration: the release workflow copies each entry verbati
the GitHub release body and the announcement discussion, so it is the first thing a
prospective user reads. CI fails a pull request whose newest entry is missing it.

## [1.76.10] - 2026-09-04

The "can be freed" and "in Recycle Bin" figures on the Cleanup tab could stop counting partway through a
folder, and could count files that live outside the folder they claim to describe. Both are now measured the
same way the cleanup itself works, so the number matches what pressing the button would actually free.

### Fixed
- **The Cleanup tab's size estimates stop under-reporting.** Both figures walked their folders with a method
that gives up entirely the moment it meets one folder Windows will not let it open — so on most machines the
total stopped early and reported less than was really there, with nothing on screen to say so. They now use
the same walker the cleanup uses, which skips what it cannot read and keeps going.
- **The Recycle Bin figure no longer counts files that are not in the bin.** The old walk followed folder
shortcuts, and deleting a folder shortcut puts one *in* the Recycle Bin — so a single deleted shortcut could
add every file it pointed at, live data included, to the "in Recycle Bin" total. The new walker never follows
them.
- **"Can be freed" no longer promises space Clean TEMP will leave alone.** Since 1.76.8 the temp cleanup skips
the folder running programs unpack themselves into. The estimate did not, so it counted bytes the cleanup
would correctly refuse to delete. It now applies the same two exclusions.

## [1.76.9] - 2026-09-04

In Volume Control, sending an app to a particular speaker or headset looked like it had not worked: the "Choose
Expand Down
61 changes: 46 additions & 15 deletions SysManager/SysManager.Tests/ArchitectureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5448,7 +5448,11 @@ public void TheTimerResolutionQuery_BindsItsOutParametersCoarsestFinestCurrent()
public void EveryTempTreeWalkerCall_PassesBothExtractionExclusions()
{
var appDir = FindAppProjectDir();
var servicesDir = Path.Combine(appDir, "Services");

// The two files allowed to sweep %TEMP% themselves. This list bounds the STRAY check below only —
// the walker-argument check further down finds its own files by looking for callers, because a
// hardcoded list there could only ever check what it was already looking at. CleanupPreScanService
// began calling the walker and went unchecked for exactly that reason.
string[] sweepers = ["TuneUpService.cs", "DeepCleanupService.cs"];
var callsChecked = 0;

Expand Down Expand Up @@ -5484,46 +5488,73 @@ public void EveryTempTreeWalkerCall_PassesBothExtractionExclusions()
+ $"TuneUpService.CleanTempFilesAsync instead of writing a third sweeper:\n "
+ string.Join("\n ", strays));

foreach (var file in sweepers)
{
// Comments stripped: both files explain this rule in prose right beside the calls, so a
// guard that read comments would pass on code that had dropped the argument.
var code = WithoutComments(File.ReadAllText(Path.Combine(servicesDir, file)));
// The files to check: the two sanctioned sweepers, PLUS any other file that calls one of the
// walkers. Only TuneUpService's are reachable from outside (DeepCleanupService's are private), so an
// outside caller is always a `...SkippingReparsePoints` call — that is what makes discovery cheap and
// exact. Comments stripped before matching: the sweepers explain this rule in prose right beside the
// calls, so a guard that read comments would pass on code that had dropped the argument.
var callers = Directory.GetFiles(appDir, "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.Select(f => (Path: f, Code: WithoutComments(File.ReadAllText(f))))
.Where(f => sweepers.Contains(Path.GetFileName(f.Path))
|| f.Code.Contains("SkippingReparsePoints(", StringComparison.Ordinal))
.ToList();

foreach (var (path, code) in callers)
{
foreach (var call in TempWalkerCall().Matches(code).Cast<Match>())
{
var args = call.Groups["args"].Value;

// Skip the declarations: their parameter list names the type, a call site never does.
if (args.Contains("CancellationToken", StringComparison.Ordinal)) continue;
// Skip the declarations. Matching the bare type name was wrong: a call site passing
// `CancellationToken.None` contains it too, so both of CleanupPreScanService's calls were
// skipped as if they were declarations — the guard read them and checked nothing. A
// declaration is the type followed by a parameter NAME; a call is followed by a dot.
if (DeclaredCancellationParameter().IsMatch(args)) continue;

callsChecked++;
var where = $"{Path.GetFileName(path)}: {call.Value}";

// BOTH, not either. OwnExtractionDirectory is one LEAF of BundleExtractionRoot, so on
// its own it spared this app's unpacked native libraries and left every other
// single-file .NET app's siblings under the same root to be deleted — the exact failure
// OwnExtractionDirectory's own documentation describes, inflicted on someone else. The
// leaf stays because for a non-single-file build BaseDirectory is the output folder,
// which no extraction root contains.
Assert.Contains("SystemPaths.BundleExtractionRoot", args, StringComparison.Ordinal);
Assert.Contains("SystemPaths.OwnExtractionDirectory", args, StringComparison.Ordinal);
Assert.True(args.Contains("SystemPaths.BundleExtractionRoot", StringComparison.Ordinal)
&& args.Contains("SystemPaths.OwnExtractionDirectory", StringComparison.Ordinal),
$"this walker call passes neither or only one extraction exclusion — {where}");
}
}

// Vacuity floor: five call sites exist today across the two services. A collapse means the
// pattern stopped matching, and this guard would then pass without reading a single call.
Assert.True(callsChecked >= 5,
$"only {callsChecked} walker calls were matched — the pattern no longer matches the call "
+ "shape, so this guard proves nothing. Re-derive it before trusting a pass.");
// Vacuity floor, re-measured: seven call sites across three files (five in the two sweepers, two in
// CleanupPreScanService). A collapse means the pattern or the declaration filter stopped matching,
// and this guard would then pass without reading a single call.
Assert.True(callsChecked >= 7,
$"only {callsChecked} walker calls were matched across {callers.Count} file(s) — the pattern no "
+ "longer matches the call shape, so this guard proves nothing. Re-derive it before trusting a "
+ "pass.");
}

/// <summary>
/// Matches a call to one of the temp-tree walkers and captures its argument list. Bounded to
/// argument lists without nested parentheses, which every current call site and declaration is.
/// </summary>
/// <remarks>
/// Deliberately loose on the name. DeepCleanupService's walkers are private and named plainly
/// (<c>EnumerateFiles</c>, <c>EnumerateDirectoriesDepthFirst</c>), so matching only
/// <c>...SkippingReparsePoints</c> silently dropped its three call sites — the vacuity floor is what
/// caught that. The <c>Directory.</c> lookbehind is what keeps the loose form from matching the BCL call
/// of the same name.
/// </remarks>
[GeneratedRegex(@"(?<!Directory\.)\bEnumerate(?:Files|Directories)\w*\((?<args>[^()]*)\)")]
private static partial Regex TempWalkerCall();

/// <summary>A <c>CancellationToken</c> PARAMETER, i.e. the type followed by a name — a declaration.</summary>
[GeneratedRegex(@"\bCancellationToken\s+\w")]
private static partial Regex DeclaredCancellationParameter();


/// <summary>
/// A style that replaces a keyboard-operable control's template must still provide a focus visual.
Expand Down
52 changes: 31 additions & 21 deletions SysManager/SysManager/Services/CleanupPreScanService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,28 @@ public Task<CleanupPreScan> MeasureAsync() =>
// profile with no override. As statics these two were called for real, walking the entire temp tree and
// every per-SID Recycle Bin folder, and that guard sat there for twenty minutes. They return a size LABEL
// rather than a path, so it was not even reporting anything — pure collateral cost.
// Both walks go through TuneUpService's walker rather than SearchOption.AllDirectories, which was wrong
// three ways at once. AllDirectories throws UnauthorizedAccessException out of MoveNext(), and the catch
// has to sit outside the foreach — so one protected subfolder ended the whole walk and the headline
// reported whatever had been summed up to that point, silently low. It also follows junctions, so bytes
// living outside the tree counted as freeable; in the Recycle Bin that is the common case, because
// deleting a junction puts a reparse point IN the bin. And the temp figure has to exclude what the sweep
// refuses to delete, or it promises space Clean TEMP will correctly leave alone.
private string MeasureTemp()
{
long bytes = 0;
var paths = new[]
{
Environment.GetEnvironmentVariable("TEMP") ?? "",
Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Temp"),
};

foreach (var path in paths.Where(p => !string.IsNullOrEmpty(p) && Directory.Exists(p)))
{
try
{
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try { bytes += new FileInfo(file).Length; }
catch (IOException) { /* skip inaccessible file */ }
catch (UnauthorizedAccessException) { /* skip protected file */ }
}
}
catch (IOException) { /* skip inaccessible directory */ }
catch (UnauthorizedAccessException) { /* skip protected directory */ }
}
// The same two exclusions CleanTempFilesAsync passes, for the same reason: this number describes
// what that method would free, and it skips both extraction roots.
var bytes = paths
.Where(p => !string.IsNullOrEmpty(p) && Directory.Exists(p))
.Sum(path => SumFileLengths(TuneUpService.EnumerateFilesSkippingReparsePoints(
path, CancellationToken.None,
SystemPaths.BundleExtractionRoot, SystemPaths.OwnExtractionDirectory)));

return Describe(bytes, "can be freed");
}
Expand All @@ -60,12 +59,9 @@ private string MeasureRecycleBin()
foreach (var path in RecycleBinHelper.CurrentUserBinPaths())
{
if (!Directory.Exists(path)) continue;
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try { bytes += new FileInfo(file).Length; }
catch (IOException) { /* skip inaccessible file */ }
catch (UnauthorizedAccessException) { /* skip protected file */ }
}
bytes += SumFileLengths(TuneUpService.EnumerateFilesSkippingReparsePoints(
path, CancellationToken.None,
SystemPaths.BundleExtractionRoot, SystemPaths.OwnExtractionDirectory));
}

return Describe(bytes, "in Recycle Bin");
Expand All @@ -74,6 +70,20 @@ private string MeasureRecycleBin()
catch (UnauthorizedAccessException) { return "Unable to scan"; }
}

/// <summary>Total size of the given files, skipping any that cannot be read.</summary>
private static long SumFileLengths(IEnumerable<string> files)
{
long bytes = 0;
foreach (var file in files)
{
try { bytes += new FileInfo(file).Length; }
catch (IOException) { /* skip inaccessible file */ }
catch (UnauthorizedAccessException) { /* skip protected file */ }
}

return bytes;
}

private static string Describe(long bytes, string suffix) =>
bytes > 0
? string.Create(CultureInfo.InvariantCulture, $"{bytes / 1024.0 / 1024.0:F1} MB {suffix}")
Expand Down
10 changes: 7 additions & 3 deletions SysManager/SysManager/Services/ICleanupPreScanService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ namespace SysManager.Services;

/// <summary>The two headline numbers Quick Cleanup shows before the user asks for anything.</summary>
/// <param name="TempLabel">
/// A finished, user-facing phrase for the temp folders — "412.7 MB can be freed", "Empty", or
/// "Unable to scan".
/// A finished, user-facing phrase for the temp folders — "412.7 MB can be freed" or "Empty". Never
/// "Unable to scan": the temp walk skips what it cannot read rather than giving up, so it always produces a
/// figure. A folder it could not open at all therefore reads as "Empty".
/// </param>
/// <param name="RecycleBinLabel">
/// The same for the current user's Recycle Bin, which can also be "Unable to scan" — enumerating the per-SID
/// folders across every fixed drive can fail outright, and reporting "Empty" for that would be a claim.
/// </param>
/// <param name="RecycleBinLabel">The same for the current user's Recycle Bin.</param>
public sealed record CleanupPreScan(string TempLabel, string RecycleBinLabel);

/// <summary>
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.76.9</Version>
<FileVersion>1.76.9.0</FileVersion>
<AssemblyVersion>1.76.9.0</AssemblyVersion>
<Version>1.76.10</Version>
<FileVersion>1.76.10.0</FileVersion>
<AssemblyVersion>1.76.10.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down