Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
58 changes: 58 additions & 0 deletions osu.Game.Tests/Database/DiskUsageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.IO;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Game.IO;

namespace osu.Game.Tests.Database
{
[TestFixture]
public class DiskUsageTests
{
private string tempDir = null!;

[SetUp]
public void SetUp()
{
// Create a temporary directory to ensure we are testing against a valid location
tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
}

[TearDown]
public void TearDown()
{
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, true);
}

[Test]
public void TestSufficientSpace()
{
// Asking for 0 bytes should always succeed (unless the drive is 100% full)
Assert.DoesNotThrow(() => DiskUsage.EnsureSufficientSpace(tempDir, 0));
}

[Test]
public void TestInsufficientSpace()
{
// Asking for the maximum possible long value should always exceed available space
Assert.Throws<IOException>(() => DiskUsage.EnsureSufficientSpace(tempDir, long.MaxValue));
}

[Test]
public void TestNonExistentDirectory()
{
string nonExistentPath = Path.Combine(tempDir, "does_not_exist");
Assert.Throws<DirectoryNotFoundException>(() => DiskUsage.EnsureSufficientSpace(nonExistentPath));
}

[Test]
public async Task TestAsyncWrapper()
{
await DiskUsage.EnsureSufficientSpaceAsync(tempDir, 0);
}
}
}
12 changes: 12 additions & 0 deletions osu.Game/Database/RealmAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
using osu.Game.Extensions;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.IO;
using osu.Game.Models;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
Expand All @@ -37,6 +38,7 @@
using osuTK.Input;
using Realms;
using Realms.Exceptions;
using FileInfo = System.IO.FileInfo;

namespace osu.Game.Database
{
Expand Down Expand Up @@ -332,6 +334,16 @@ private Realm prepareFirstRealmAccess()
}
catch { }

// Check for requisite disk space
try
{
DiskUsage.EnsureSufficientSpace(storage.GetFullPath(string.Empty));
}
catch (IOException)
{
Logger.Log("Your device is running low on disk space! Please free up some space to avoid potential issues.", LoggingTarget.Runtime, LogLevel.Important);
}

try
{
return getRealmInstance();
Expand Down
50 changes: 50 additions & 0 deletions osu.Game/IO/DiskUsage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.IO;
using System.Threading.Tasks;
using osu.Framework.Logging;

namespace osu.Game.IO
{
public static class DiskUsage
{
/// <summary>
/// 500 MiB
/// </summary>
private const long required_space_default = 512L * 1024L * 1024L;

/// <summary>
/// Checks if the available free space on the drive containing the path is sufficient for normal operation.
/// This method is blocking, and <see cref="EnsureSufficientSpaceAsync"/> should be preferred in IO-bound scenarios.
/// </summary>
/// <param name="checkPath">A path to a file or directory in which the drive's disk space should be checked.</param>
/// <param name="requiredSpace">The amount of space to ensure is available in bytes. Defaults to <see cref="required_space_default"/>.</param>
public static void EnsureSufficientSpace(string checkPath, long requiredSpace = required_space_default)
{
if (!Directory.Exists(checkPath))
throw new DirectoryNotFoundException($"The directory '{checkPath}' does not exist or could not be found.");

string? validPathRoot = Path.GetPathRoot(checkPath);
Copy link
Member

@Susko3 Susko3 Dec 11, 2025

Choose a reason for hiding this comment

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

If I'm reading the documentation correctly, this will basically always return "/" on non-Windows, making this code invalid if osu! is on an external disk. Have you checked that this correctly reports the free size when querying directories on external disks on macOS?

Copy link
Contributor Author

@smallketchup82 smallketchup82 Dec 11, 2025

Choose a reason for hiding this comment

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

I have not, thanks for catching that. Should be fixed in fb73dec

Using GetPathRoot() does always return "/". Fortunately, my main use for that method was simply to check if the provided path is valid, so changing it to GetFullPath() properly provides the correct disk space while still checking path validity.

Here's a test on my Linux PC, having switched to GetFullPath(), testing for "/" and my HDD:
image

Which is roughly consistent with what my OS reports for my hard drive:
image

Note: The reason for the large difference here is that the logger reports the available space in MiB, while duf reports in GB. Convert the 385,103 MiB to GB and you get 403GB, which is exactly what my OS reports.
The reason for that second discrepancy (376GB vs 403GB), is due to the fact that duf reports btrfs's actual free space including metadata usage, while DiskInfo just checks whatever my OS returns. The available space for my HDD is reported as 403GB in nautilus, so everything here is accurate and working as intended, just confusing due to linux shenanigans.


if (string.IsNullOrEmpty(validPathRoot))
throw new IOException($"The directory '{checkPath}' is not a valid path.");

var activeDriveInfo = new DriveInfo(validPathRoot);

long availableFreeSpace = activeDriveInfo.AvailableFreeSpace;

#if DEBUG
Logger.Log($"Available disk space: {availableFreeSpace / 1048576L} MiB");
#endif

if (availableFreeSpace < requiredSpace)
throw new IOException($"Insufficient disk space available! Required: {requiredSpace} | Available: {availableFreeSpace}");
}

public static async Task EnsureSufficientSpaceAsync(string checkDirectory, long requiredSpace = required_space_default)
{
await Task.Run(() => EnsureSufficientSpace(checkDirectory, requiredSpace)).ConfigureAwait(false);
}
Copy link
Member

Choose a reason for hiding this comment

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

I'd be worried about usages of this in something like an import operation firing far too many checks.

Also I think you can make this return the task directly rather than adding an extra async layer:

Suggested change
public static async Task EnsureSufficientSpaceAsync(string checkDirectory, long requiredSpace = required_space_default)
{
await Task.Run(() => EnsureSufficientSpace(checkDirectory, requiredSpace)).ConfigureAwait(false);
}
public static Task EnsureSufficientSpaceAsync(string checkDirectory, long requiredSpace = required_space_default)
{
return Task.Run(() => EnsureSufficientSpace(checkDirectory, requiredSpace));
}

Copy link
Contributor Author

@smallketchup82 smallketchup82 Dec 11, 2025

Choose a reason for hiding this comment

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

I've applied your suggestion in c641f0a

I did originally consider adding debouncing logic here. My original idea was to add a DateTime as a property of DiskUsage, storing DateTime.Now in it whenever EnsureSufficientSpace is called, and silently return in said method if it hasn't yet been 5 minutes since the last call.

I decided to leave that out of this PR as the async wrapper is mostly just future-proofing, and this logic could be added in the future if the async wrapper does ever end up being used in real-time contexts. But I can see the confusion in supplying this method, but not going the full mile and specializing it for its intended purpose 😅

I can probably just remove the async wrapper altogether if we decide to stick with the "only on startup" approach. The actual overhead of checking disk space isn't really high, but it can get bad if done in a loop.

}
}
Loading