// 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 { /// /// Experimental implementation of which wraps the native C++ API. /// 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); /// /// A buffer used for native code to stream data into /// class PinnedBuffer : IDisposable { static readonly byte[] s_guardBytes = Enumerable.Repeat(0xfd, 16).ToArray(); public byte[] AllocatedData { get; private set; } public Memory 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(); } } /// /// Response object for a request /// 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 Data => (_buffer == null) ? ReadOnlyMemory.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 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 _writeBuffers = new BlockingCollection(); Response? _currentResponse; readonly ManualResetEvent _responseCompleteEvent; readonly Stopwatch _stallTimer = new Stopwatch(); readonly HangMonitor _hangMonitor; bool _disposed; /// public IPerforceSettings Settings { get; } /// public ILogger Logger { get; } /// /// Constructor /// /// Settings for the connection /// Logger for messages public NativePerforceConnection(IPerforceSettings settings, ILogger logger) : this(settings, 2, 128 * 1024, logger) { } /// /// Constructor /// /// Settings for the connection /// Number of buffers to create for streaming response data /// Size of each buffer /// Logger for messages 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); } /// /// Create an instance of the native client /// /// /// /// public static async Task CreateAsync(IPerforceSettings settings, ILogger logger) { NativePerforceConnection? connection = null; try { connection = new NativePerforceConnection(settings, logger); await connection.ConnectAsync(); return connection; } catch { connection?.Dispose(); throw; } } /// /// Check whether the native client is supported on the current platform /// /// 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(); } /// /// Finalizer /// ~NativePerforceConnection() { Dispose(false); } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// 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; } } /// /// Gets the amount of time stalled waiting for an output buffer in the last command /// public TimeSpan StallTime => _stallTimer.Elapsed; /// /// Initializes the connection, throwing an error on failure /// private async Task ConnectAsync() { PerforceError? error = await TryConnectAsync(); if (error != null) { throw new PerforceException(error); } } /// /// Tries to initialize the connection /// /// Error returned when attempting to connect private async Task 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 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; } /// /// 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. /// 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; } } } /// /// Callback for switching buffers /// /// The complete buffer /// Receives information about the next buffer to write to 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); } /// public IPerforceOutput Command(string command, IReadOnlyList arguments, IReadOnlyList? fileArguments, byte[]? inputData, string? promptResponse, bool interceptIo) { byte[]? specData = null; if (inputData != null) { specData = Encoding.UTF8.GetBytes(FormatSpec(inputData)); } List allArguments = new List(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 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 nativeArgs = new List(); 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; } /// /// Converts a Python marshalled data blob to a spec definition /// static string FormatSpec(byte[] inputData) { int pos = 0; List> rows = new List>(); 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(); } /// public PerforceRecord CreateRecord(List> fields) => PerforceRecord.FromFields(fields, false); } }