Files
UnrealEngine/Engine/Source/Programs/Shared/EpicGames.Perforce/NativePerforceConnection.cs
2025-05-18 13:04:45 +08:00

736 lines
20 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using EpicGames.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace EpicGames.Perforce
{
/// <summary>
/// Experimental implementation of <see cref="IPerforceConnection"/> which wraps the native C++ API.
/// </summary>
public sealed class NativePerforceConnection : IPerforceConnection, IDisposable
{
const string NativeDll = "EpicGames.Perforce.Native";
[StructLayout(LayoutKind.Sequential)]
class NativeSettings
{
[MarshalAs(UnmanagedType.LPStr)]
public string? _serverAndPort;
[MarshalAs(UnmanagedType.LPStr)]
public string? _userName;
[MarshalAs(UnmanagedType.LPStr)]
public string? _password;
[MarshalAs(UnmanagedType.LPStr)]
public string? _hostName;
[MarshalAs(UnmanagedType.LPStr)]
public string? _clientName;
[MarshalAs(UnmanagedType.LPStr)]
public string? _appName;
[MarshalAs(UnmanagedType.LPStr)]
public string? _appVersion;
}
[StructLayout(LayoutKind.Sequential)]
[SuppressMessage("Compiler", "CA1812")]
class NativeReadBuffer
{
public IntPtr _data;
public int _length;
public int _count;
public int _maxLength;
public int _maxCount;
};
[StructLayout(LayoutKind.Sequential)]
class NativeWriteBuffer
{
public IntPtr _data;
public int _maxLength;
public int _maxCount;
};
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
delegate void OnBufferReadyFn(NativeReadBuffer readBuffer, [In, Out] NativeWriteBuffer writeBuffer);
[DllImport(NativeDll, CallingConvention = CallingConvention.Cdecl)]
static extern IntPtr Client_Create(NativeSettings? settings, NativeWriteBuffer writeBuffer, IntPtr onBufferReadyFnPtr);
[DllImport(NativeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)]
static extern void Client_Command(IntPtr client, [MarshalAs(UnmanagedType.LPStr)] string command, int numArgs, IntPtr[] args, byte[]? inputData, int inputLength, byte[]? promptResponse, bool interceptIo);
[DllImport(NativeDll, CallingConvention = CallingConvention.Cdecl)]
static extern void Client_Destroy(IntPtr client);
/// <summary>
/// A buffer used for native code to stream data into
/// </summary>
class PinnedBuffer : IDisposable
{
static readonly byte[] s_guardBytes = Enumerable.Repeat<byte>(0xfd, 16).ToArray();
public byte[] AllocatedData { get; private set; }
public Memory<byte> Data { get; private set; }
public GCHandle Handle { get; private set; }
public IntPtr BasePtr { get; private set; }
public int MaxLength => Data.Length;
public PinnedBuffer(int maxLength)
{
AllocatedData = null!;
AllocateBuffer(maxLength);
}
void AllocateBuffer(int maxLength)
{
AllocatedData = new byte[maxLength + (s_guardBytes.Length * 2)];
Data = AllocatedData.AsMemory(s_guardBytes.Length, maxLength);
Handle = GCHandle.Alloc(AllocatedData, GCHandleType.Pinned);
BasePtr = Handle.AddrOfPinnedObject() + s_guardBytes.Length;
s_guardBytes.AsSpan().CopyTo(AllocatedData);
s_guardBytes.AsSpan().CopyTo(AllocatedData.AsSpan(AllocatedData.Length - s_guardBytes.Length));
}
public void CheckGuardBytes()
{
if (!AllocatedData.AsSpan(0, s_guardBytes.Length).SequenceEqual(s_guardBytes))
{
throw new InvalidOperationException("Guard bytes corrupted at start of memory region");
}
if (!AllocatedData.AsSpan(AllocatedData.Length - s_guardBytes.Length).SequenceEqual(s_guardBytes))
{
throw new InvalidOperationException("Guard bytes corrupted at end of memory region");
}
}
public void Resize(int maxLength)
{
byte[] oldData = AllocatedData;
Handle.Free();
AllocateBuffer(maxLength);
oldData.CopyTo(AllocatedData, 0);
}
public void Dispose()
{
Handle.Free();
}
}
/// <summary>
/// Response object for a request
/// </summary>
class Response : IPerforceOutput
{
readonly NativePerforceConnection _outer;
PinnedBuffer? _buffer;
int _bufferPos;
int _bufferLen;
PinnedBuffer? _nextBuffer;
int _nextBufferPos;
int _nextBufferLen;
public Channel<(PinnedBuffer Buffer, int Length)> _readBuffers = Channel.CreateUnbounded<(PinnedBuffer, int)>();
public ReadOnlyMemory<byte> Data => (_buffer == null) ? ReadOnlyMemory<byte>.Empty : _buffer.Data.Slice(_bufferPos, _bufferLen - _bufferPos);
public Response(NativePerforceConnection outer)
{
_outer = outer;
}
public async ValueTask DisposeAsync()
{
if (_buffer != null)
{
_outer._writeBuffers.Add(_buffer);
}
if (_nextBuffer != null)
{
_outer._writeBuffers.Add(_nextBuffer);
}
while (await _readBuffers.Reader.WaitToReadAsync())
{
(PinnedBuffer, int) buffer;
if (_readBuffers.Reader.TryRead(out buffer))
{
_outer._writeBuffers.Add(buffer.Item1);
}
}
_outer._responseCompleteEvent.Set();
}
async Task<(PinnedBuffer?, int)> GetNextReadBufferAsync()
{
for (; ; )
{
if (!await _readBuffers.Reader.WaitToReadAsync())
{
return (null, 0);
}
(PinnedBuffer, int) pair;
if (_readBuffers.Reader.TryRead(out pair))
{
return pair;
}
}
}
public async Task<bool> ReadAsync(CancellationToken token)
{
// If we don't have any data yet, wait until a read completes
if (_buffer == null)
{
(_buffer, _bufferLen) = await GetNextReadBufferAsync();
return _buffer != null;
}
// If we've used up all the data in the buffer, return it to the write list and move to the next one.
int originalBufferLen = _bufferLen - _nextBufferPos;
if (_bufferPos >= originalBufferLen)
{
_outer._writeBuffers.TryAdd(_buffer!);
_buffer = _nextBuffer;
_bufferPos -= originalBufferLen;
_bufferLen = _nextBufferLen;
_nextBuffer = null;
_nextBufferPos = 0;
_nextBufferLen = 0;
return true;
}
// Ensure there's some space in the current buffer. In order to handle cases where we want to read data straddling both buffers, copy 16k chunks
// back to the first buffer until we can read entirely from the second buffer.
int maxAppend = _buffer.MaxLength - _bufferLen;
if (maxAppend == 0)
{
if (_bufferPos > 0)
{
_buffer.Data.Slice(_bufferPos, _bufferLen - _bufferPos).CopyTo(_buffer.Data);
_bufferLen -= _bufferPos;
_bufferPos = 0;
}
else
{
_buffer.Resize(_buffer.MaxLength + 16384);
}
maxAppend = _buffer.MaxLength - _bufferLen;
}
// Read the next buffer
if (_nextBuffer == null)
{
(_nextBuffer, _nextBufferLen) = await GetNextReadBufferAsync();
if (_nextBuffer == null)
{
return false;
}
}
// Try to copy some data from the next buffer
int copyLen = Math.Min(_nextBufferLen - _nextBufferPos, Math.Min(maxAppend, 16384));
_nextBuffer.Data.Slice(_nextBufferPos, copyLen).CopyTo(_buffer.Data.Slice(_bufferLen));
_bufferLen += copyLen;
_nextBufferPos += copyLen;
// If we've read everything from the next buffer, return it to the write list
if (_nextBufferPos == _nextBufferLen)
{
_outer._writeBuffers.Add(_nextBuffer, token);
_nextBuffer = null;
_nextBufferPos = 0;
_nextBufferLen = 0;
}
return true;
}
public void Discard(int numBytes)
{
// Update the read position
_bufferPos += numBytes;
Debug.Assert(_bufferPos <= _bufferLen);
}
}
static int s_nextUniqueId;
IntPtr _client;
readonly int _uniqueId;
readonly PinnedBuffer[] _buffers;
readonly OnBufferReadyFn _onBufferReadyInst;
readonly IntPtr _onBufferReadyFnPtr;
Thread? _backgroundThread;
readonly BlockingCollection<(Action, Response)?> _requests = new BlockingCollection<(Action, Response)?>();
readonly BlockingCollection<PinnedBuffer> _writeBuffers = new BlockingCollection<PinnedBuffer>();
Response? _currentResponse;
readonly ManualResetEvent _responseCompleteEvent;
readonly Stopwatch _stallTimer = new Stopwatch();
readonly HangMonitor _hangMonitor;
bool _disposed;
/// <inheritdoc/>
public IPerforceSettings Settings { get; }
/// <inheritdoc/>
public ILogger Logger { get; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="settings">Settings for the connection</param>
/// <param name="logger">Logger for messages</param>
public NativePerforceConnection(IPerforceSettings settings, ILogger logger)
: this(settings, 2, 128 * 1024, logger)
{
}
/// <summary>
/// Constructor
/// </summary>
/// <param name="settings">Settings for the connection</param>
/// <param name="bufferCount">Number of buffers to create for streaming response data</param>
/// <param name="bufferSize">Size of each buffer</param>
/// <param name="logger">Logger for messages</param>
public NativePerforceConnection(IPerforceSettings settings, int bufferCount, int bufferSize, ILogger logger)
{
Settings = settings;
Logger = logger;
_uniqueId = Interlocked.Increment(ref s_nextUniqueId);
ILogger hangLogger = settings.EnableHangMonitor ? logger : NullLogger.Instance;
_hangMonitor = new HangMonitor(TimeSpan.FromMinutes(1.0), $"Perforce connection ({_uniqueId})", hangLogger);
_buffers = new PinnedBuffer[bufferCount];
for (int idx = 0; idx < bufferCount; idx++)
{
_buffers[idx] = new PinnedBuffer(bufferSize);
_writeBuffers.TryAdd(_buffers[idx]);
}
_onBufferReadyInst = new OnBufferReadyFn(OnBufferReady);
_onBufferReadyFnPtr = Marshal.GetFunctionPointerForDelegate(_onBufferReadyInst);
_responseCompleteEvent = new ManualResetEvent(false);
_backgroundThread = new Thread(BackgroundThreadProc);
_backgroundThread.IsBackground = true;
_backgroundThread.Start();
logger.LogTrace("Created Perforce connection {ConnectionId} (server: {ServerAndPort}, user: {UserName}, client: {ClientName})", _uniqueId, settings.ServerAndPort, settings.UserName, settings.ClientName);
}
/// <summary>
/// Create an instance of the native client
/// </summary>
/// <param name="settings"></param>
/// <param name="logger"></param>
/// <returns></returns>
public static async Task<NativePerforceConnection> CreateAsync(IPerforceSettings settings, ILogger logger)
{
NativePerforceConnection? connection = null;
try
{
connection = new NativePerforceConnection(settings, logger);
await connection.ConnectAsync();
return connection;
}
catch
{
connection?.Dispose();
throw;
}
}
/// <summary>
/// Check whether the native client is supported on the current platform
/// </summary>
/// <returns></returns>
public static bool IsSupported()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return RuntimeInformation.OSArchitecture != Architecture.Arm64;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return RuntimeInformation.OSArchitecture != Architecture.Arm64;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return RuntimeInformation.OSArchitecture != Architecture.Arm64;
}
return false;
}
void GetNextWriteBuffer(NativeWriteBuffer nativeWriteBuffer, int minSize)
{
_stallTimer.Start();
_hangMonitor.Tick();
PinnedBuffer buffer = _writeBuffers.Take();
if (buffer.MaxLength < minSize)
{
buffer.Resize(minSize);
}
nativeWriteBuffer._data = buffer.BasePtr;
nativeWriteBuffer._maxLength = buffer.Data.Length;
nativeWriteBuffer._maxCount = Int32.MaxValue;
_hangMonitor.Tick();
_stallTimer.Stop();
}
/// <summary>
/// Finalizer
/// </summary>
~NativePerforceConnection()
{
Dispose(false);
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <inheritdoc/>
void Dispose(bool disposing)
{
if (disposing && !_disposed)
{
using IDisposable scope = _hangMonitor.Start("Disposing");
Logger.LogTrace("Disposing Perforce connection {ConnectionId}", _uniqueId);
if (_backgroundThread != null)
{
_requests.Add(null);
_requests.CompleteAdding();
_backgroundThread.Join();
_backgroundThread = null!;
}
_requests.Dispose();
}
if (_client != IntPtr.Zero)
{
Client_Destroy(_client);
_client = IntPtr.Zero;
}
if (disposing && !_disposed)
{
using IDisposable scope = _hangMonitor.Start("Disposing");
_writeBuffers.Dispose();
_responseCompleteEvent.Dispose();
foreach (PinnedBuffer pinnedBuffer in _buffers)
{
pinnedBuffer.Dispose();
}
_hangMonitor.Dispose();
_disposed = true;
}
}
/// <summary>
/// Gets the amount of time stalled waiting for an output buffer in the last command
/// </summary>
public TimeSpan StallTime => _stallTimer.Elapsed;
/// <summary>
/// Initializes the connection, throwing an error on failure
/// </summary>
private async Task ConnectAsync()
{
PerforceError? error = await TryConnectAsync();
if (error != null)
{
throw new PerforceException(error);
}
}
/// <summary>
/// Tries to initialize the connection
/// </summary>
/// <returns>Error returned when attempting to connect</returns>
private async Task<PerforceError?> TryConnectAsync()
{
await using Response response = new Response(this);
NativeSettings? nativeSettings = null;
if (Settings != null)
{
nativeSettings = new NativeSettings();
nativeSettings._serverAndPort = Settings.ServerAndPort;
nativeSettings._userName = Settings.UserName;
nativeSettings._password = Settings.Password;
nativeSettings._hostName = Settings.HostName;
nativeSettings._clientName = Settings.ClientName;
nativeSettings._appName = Settings.AppName;
nativeSettings._appVersion = Settings.AppVersion;
}
_requests.Add((() =>
{
NativeWriteBuffer writeBuffer = new NativeWriteBuffer();
GetNextWriteBuffer(writeBuffer, 1);
_client = Client_Create(nativeSettings, writeBuffer, _onBufferReadyFnPtr);
}, response));
List<PerforceResponse> records = await ((IPerforceOutput)response).ReadResponsesAsync(null, default);
if (records.Count != 1)
{
throw new PerforceException("Expected at least one record to be returned from Init() call.");
}
PerforceError? error = records[0].Error;
if (error == null)
{
throw new PerforceException("Unexpected response from init call");
}
if (error.Severity != PerforceSeverityCode.Empty)
{
return error;
}
return null;
}
/// <summary>
/// Background thread which sequences requests on a single thread. The Perforce API isn't async aware, but is primarily
/// I/O bound, so this thread will mostly be idle. All processing C#-side is done using async tasks, whereas this thread
/// blocks.
/// </summary>
void BackgroundThreadProc()
{
for (; ; )
{
try
{
(Action Action, Response Response)? request = _requests.Take();
if (request == null)
{
break;
}
_currentResponse = request.Value.Response;
_responseCompleteEvent.Reset();
_stallTimer.Reset();
request.Value.Action();
_currentResponse._readBuffers.Writer.TryComplete();
_responseCompleteEvent.WaitOne();
_currentResponse = null;
}
catch (Exception ex)
{
Logger.LogError(ex, "Exception while processing Perforce commands: {Message}", ex.Message);
throw;
}
}
}
/// <summary>
/// Callback for switching buffers
/// </summary>
/// <param name="readBuffer">The complete buffer</param>
/// <param name="writeBuffer">Receives information about the next buffer to write to</param>
void OnBufferReady(NativeReadBuffer readBuffer, [In, Out] NativeWriteBuffer writeBuffer)
{
PinnedBuffer buffer = _buffers.First(x => x.BasePtr == readBuffer._data);
buffer.CheckGuardBytes();
_currentResponse!._readBuffers.Writer.TryWrite((buffer, readBuffer._length)); // Unbounded; will always succeed
int nextWriteSize = 0;
if (readBuffer._length == 0)
{
nextWriteSize = readBuffer._maxLength * 2;
}
GetNextWriteBuffer(writeBuffer, nextWriteSize);
}
/// <inheritdoc/>
public IPerforceOutput Command(string command, IReadOnlyList<string> arguments, IReadOnlyList<string>? fileArguments, byte[]? inputData, string? promptResponse, bool interceptIo)
{
byte[]? specData = null;
if (inputData != null)
{
specData = Encoding.UTF8.GetBytes(FormatSpec(inputData));
}
List<string> allArguments = new List<string>(arguments);
if (fileArguments != null)
{
allArguments.AddRange(fileArguments);
}
Response response = new Response(this);
_requests.Add((() => ExecCommand(command, allArguments, specData, promptResponse, interceptIo), response));
return response;
}
private void ExecCommand(string command, List<string> args, byte[]? inputData, string? promptResponse, bool interceptIo)
{
StringBuilder argList = new StringBuilder();
for (int idx = 0; idx < args.Count; idx++)
{
CommandLineArguments.Append(argList, args[idx]);
if (argList.Length > 512)
{
argList.Append($" {{+{args.Count - idx - 1} more}}");
break;
}
}
Stopwatch timer = Stopwatch.StartNew();
Logger.LogTrace("Conn {ConnectionId}: {Command} {Args}", _uniqueId, command, argList.ToString());
using IDisposable scope = _hangMonitor.Start($"{command} {argList}");
long initialAllocatedSize = GetAllocatedSize();
List<IntPtr> nativeArgs = new List<IntPtr>();
try
{
foreach (string arg in args)
{
byte[] data = Encoding.UTF8.GetBytes(arg);
Array.Resize(ref data, data.Length + 1);
IntPtr nativeArg = Marshal.AllocHGlobal(data.Length);
Marshal.Copy(data, 0, nativeArg, data.Length);
nativeArgs.Add(nativeArg);
}
byte[]? promptResponseBytes = null;
if (promptResponse != null)
{
promptResponseBytes = new byte[Encoding.UTF8.GetByteCount(promptResponse) + 1];
Encoding.UTF8.GetBytes(promptResponse, promptResponseBytes);
}
try
{
Client_Command(_client, command, nativeArgs.Count, nativeArgs.ToArray(), inputData, inputData?.Length ?? 0, promptResponseBytes, interceptIo);
}
catch (Exception ex)
{
Logger.LogError(ex, "Exception while executing command {Command} {Args}: {Message}", command, argList.ToString(), ex.Message);
throw;
}
}
finally
{
for (int idx = 0; idx < nativeArgs.Count; idx++)
{
Marshal.FreeHGlobal(nativeArgs[idx]);
}
}
const long WarnSize = 16 * 1024 * 1024;
long allocatedSize = GetAllocatedSize();
if (allocatedSize > initialAllocatedSize && allocatedSize > WarnSize)
{
Logger.LogTrace("Native P4 connection allocation increased {InitialAllocatedSize} -> {AllocatedSize} ({Command} {Args})", initialAllocatedSize, allocatedSize, command, argList.ToString());
}
Logger.LogTrace("Conn {ConnectionId}: Request completed in {Time}ms", _uniqueId, timer.ElapsedMilliseconds);
}
long GetAllocatedSize()
{
long allocatedSize = 0;
foreach (PinnedBuffer buffer in _buffers)
{
allocatedSize += buffer.MaxLength;
}
return allocatedSize;
}
/// <summary>
/// Converts a Python marshalled data blob to a spec definition
/// </summary>
static string FormatSpec(byte[] inputData)
{
int pos = 0;
List<KeyValuePair<Utf8String, PerforceValue>> rows = new List<KeyValuePair<Utf8String, PerforceValue>>();
if (!PerforceOutput.ParseRecord(inputData, ref pos, rows))
{
throw new PerforceException("Unable to parse input data as record");
}
if (pos != inputData.Length)
{
throw new PerforceException("Garbage after end of spec data");
}
StringBuilder result = new StringBuilder();
foreach ((Utf8String key, PerforceValue value) in rows)
{
string[] valueLines = value.ToString().Split('\n');
if (valueLines.Length == 1)
{
result.AppendLine($"{key}: {valueLines[0]}");
}
else
{
result.AppendLine($"{key}:");
foreach (string valueLine in valueLines)
{
result.AppendLine($"\t{valueLine}");
}
}
result.AppendLine();
}
return result.ToString();
}
/// <inheritdoc/>
public PerforceRecord CreateRecord(List<KeyValuePair<string, object>> fields) => PerforceRecord.FromFields(fields, false);
}
}