Files
duplicati/Duplicati/Library/Snapshots/MacOS/MacOSPhotosNativeStream.cs
T
Kenneth Skovhede 657467e409 Fix MacOS Photos memory usage
This PR fixes a memory usage issue with MacOS Photos integration.

Prior to this fix, Duplicati would allow the Photos library to stream an unlimited amount of data. For large files this would require all resources to be kept fully in memory.

With this fix, the reader is limited to 8MB read-ahead, such that the maximum size of memory use is bounded by 8MBxReaders, where readers are default cores/2.

Additionally, this significantly reduces the memory allocation/deallocation in the C# layer when processing PhotoKit entries.
2026-07-02 11:32:54 +02:00

159 lines
5.4 KiB
C#

// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
namespace Duplicati.Library.Snapshots.MacOS;
[SupportedOSPlatform("macOS")]
internal sealed class MacOSPhotosNativeStream : Stream
{
private readonly MacOSPhotosNative.SafeAssetHandle handle;
private readonly long length;
private long position;
private bool disposed;
public MacOSPhotosNativeStream(MacOSPhotosNative.SafeAssetHandle handle, long length)
{
this.handle = handle ?? throw new ArgumentNullException(nameof(handle));
this.length = length;
this.position = 0;
}
public override bool CanRead => !disposed;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => disposed ? throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream)) : length;
public override long Position
{
get => disposed ? throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream)) : position;
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
ValidateReadArguments(buffer, offset, count);
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
if (count == 0)
return 0;
var bytesRead = MacOSPhotosNative.ReadAsset(handle, new Memory<byte>(buffer, offset, count));
position += bytesRead;
return bytesRead;
}
public override int Read(Span<byte> buffer)
{
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
var bytesRead = MacOSPhotosNative.ReadAsset(handle, buffer);
position += bytesRead;
return bytesRead;
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
var bytesRead = MacOSPhotosNative.ReadAsset(handle, buffer);
position += bytesRead;
return ValueTask.FromResult(bytesRead);
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
ValidateReadArguments(buffer, offset, count);
cancellationToken.ThrowIfCancellationRequested();
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
if (count == 0)
return Task.FromResult(0);
var bytesRead = MacOSPhotosNative.ReadAsset(handle, new Memory<byte>(buffer, offset, count));
position += bytesRead;
return Task.FromResult(bytesRead);
}
public override long Seek(long offset, SeekOrigin origin)
=> throw new NotSupportedException();
public override void SetLength(long value)
=> throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count)
=> throw new NotSupportedException();
public override void Write(ReadOnlySpan<byte> buffer)
=> throw new NotSupportedException();
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
handle.Dispose();
disposed = true;
}
base.Dispose(disposing);
}
private static void ValidateReadArguments(byte[] buffer, int offset, int count)
{
if (buffer is null)
throw new ArgumentNullException(nameof(buffer));
if ((uint)offset > buffer.Length)
throw new ArgumentOutOfRangeException(nameof(offset));
if ((uint)count > buffer.Length - offset)
throw new ArgumentOutOfRangeException(nameof(count));
}
}