Files
ichni_Official/Packages/com.tivadar.best.http/Runtime/HTTP/Hosts/Connections/RequestEvents.cs
2026-06-15 18:18:16 +08:00

655 lines
26 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using Best.HTTP.Caching;
using Best.HTTP.HostSetting;
using Best.HTTP.Request.Settings;
using Best.HTTP.Request.Timings;
using Best.HTTP.Shared;
using Best.HTTP.Shared.Extensions;
namespace Best.HTTP.Hosts.Connections
{
public enum RequestEvents
{
Upgraded,
DownloadProgress,
UploadProgress,
StreamingData,
DownloadStarted,
StateChange,
SetState,
QueuedResend,
Resend,
Headers,
Timing,
}
public readonly struct RequestEventInfo
{
public readonly HTTPRequest SourceRequest;
public readonly RequestEvents Event;
public readonly HTTPRequestStates State;
public readonly Exception Error;
public readonly long Progress;
public readonly long ProgressLength;
public readonly byte[] Data;
public readonly int DataLength;
public readonly TimingEventInfo timingEvent;
// Headers
public readonly Dictionary<string, List<string>> Headers;
public RequestEventInfo(HTTPRequest request, RequestEvents @event)
{
this.SourceRequest = request;
this.Event = @event;
this.State = HTTPRequestStates.Initial;
this.Error = null;
this.Progress = this.ProgressLength = 0;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = null;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, RequestEvents @event, HTTPRequestStates newState)
{
this.SourceRequest = request;
this.Event = @event;
this.State = newState;
this.Error = null;
this.Progress = this.ProgressLength = 0;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = null;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, HTTPRequestStates newState)
{
this.SourceRequest = request;
this.Event = RequestEvents.StateChange;
this.State = newState;
this.Error = null;
this.Progress = this.ProgressLength = 0;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = null;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, HTTPRequestStates newState, Exception error)
{
this.SourceRequest = request;
this.Event = RequestEvents.SetState;
this.State = newState;
this.Error = error;
this.Progress = this.ProgressLength = 0;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = null;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, RequestEvents @event, long progress, long progressLength)
{
this.SourceRequest = request;
this.Event = @event;
this.State = HTTPRequestStates.Initial;
this.Error = null;
this.Progress = progress;
this.ProgressLength = progressLength;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = null;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, byte[] data, int dataLength)
{
this.SourceRequest = request;
this.Event = RequestEvents.StreamingData;
this.State = HTTPRequestStates.Initial;
this.Error = null;
this.Progress = this.ProgressLength = 0;
this.Data = data;
this.DataLength = dataLength;
// Headers
this.Headers = null;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, Dictionary<string, List<string>> headers)
{
this.SourceRequest = request;
this.Event = RequestEvents.Headers;
this.State = HTTPRequestStates.Initial;
this.Error = null;
this.Progress = this.ProgressLength = 0;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = headers;
this.timingEvent = default;
}
public RequestEventInfo(HTTPRequest request, TimingEventInfo timingEvent)
{
this.SourceRequest = request;
this.Event = RequestEvents.Timing;
this.State = HTTPRequestStates.Initial;
this.Error = null;
this.Progress = this.ProgressLength = 0;
this.Data = null;
this.DataLength = 0;
// Headers
this.Headers = null;
this.timingEvent = timingEvent;
}
public override string ToString()
{
switch (this.Event)
{
case RequestEvents.Upgraded:
return string.Format("[RequestEventInfo Event: Upgraded, Source: {0}]", this.SourceRequest.Context.Hash);
case RequestEvents.DownloadProgress:
return string.Format("[RequestEventInfo Event: DownloadProgress, Progress: {1}, ProgressLength: {2}, Source: {0}]", this.SourceRequest.Context.Hash, this.Progress, this.ProgressLength);
case RequestEvents.UploadProgress:
return string.Format("[RequestEventInfo Event: UploadProgress, Progress: {1}, ProgressLength: {2}, Source: {0}]", this.SourceRequest.Context.Hash, this.Progress, this.ProgressLength);
case RequestEvents.StreamingData:
return string.Format("[RequestEventInfo Event: StreamingData, DataLength: {1}, Source: {0}]", this.SourceRequest.Context.Hash, this.DataLength);
case RequestEvents.DownloadStarted:
return $"[RequestEventInfo Event: DownloadStarted, Source: {this.SourceRequest.Context.Hash}]";
case RequestEvents.StateChange:
return string.Format("[RequestEventInfo Event: StateChange, State: {1}, Source: {0}]", this.SourceRequest.Context.Hash, this.State);
case RequestEvents.SetState:
return string.Format("[RequestEventInfo Event: SetState, State: {1}, Source: {0}]", this.SourceRequest.Context.Hash, this.State);
case RequestEvents.Resend:
return string.Format("[RequestEventInfo Event: Resend, Source: {0}]", this.SourceRequest.Context.Hash);
case RequestEvents.Headers:
return string.Format("[RequestEventInfo Event: Headers, Source: {0}]", this.SourceRequest.Context.Hash);
case RequestEvents.QueuedResend:
return $"[RequestEventInfo Event: QueuedResend, Source: {this.SourceRequest.Context.Hash}]";
case RequestEvents.Timing:
return $"[RequestEventInfo Event: Timing, TimingEvent: {this.timingEvent}, Source:{this.SourceRequest.Context.Hash}]";
default:
throw new NotImplementedException(this.Event.ToString());
}
}
}
class ProgressFlattener
{
struct FlattenedProgress
{
public HTTPRequest request;
public OnProgressDelegate onProgress;
public long progress;
public long length;
}
private FlattenedProgress[] progresses;
private bool hasProgress;
public void InsertOrUpdate(RequestEventInfo info, OnProgressDelegate onProgress)
{
if (progresses == null)
progresses = new FlattenedProgress[1];
hasProgress = true;
var newProgress = new FlattenedProgress { request = info.SourceRequest, progress = info.Progress, length = info.ProgressLength, onProgress = onProgress };
int firstEmptyIdx = -1;
for (int i = 0; i < progresses.Length; i++)
{
var progress = progresses[i];
if (object.ReferenceEquals(progress.request, info.SourceRequest))
{
progresses[i] = newProgress;
return;
}
if (firstEmptyIdx == -1 && progress.request == null)
firstEmptyIdx = i;
}
if (firstEmptyIdx == -1)
{
Array.Resize(ref progresses, progresses.Length + 1);
progresses[progresses.Length - 1] = newProgress;
}
else
progresses[firstEmptyIdx] = newProgress;
}
public void DispatchProgressCallbacks()
{
if (progresses == null || !hasProgress)
return;
for (int i = 0; i < progresses.Length; ++i)
{
var @event = progresses[i];
var source = @event.request;
if (source != null && @event.onProgress != null)
{
try
{
@event.onProgress(source, @event.progress, @event.length);
}
catch (Exception ex)
{
HTTPManager.Logger.Exception("ProgressFlattener", "DispatchProgressCallbacks", ex, source.Context);
}
}
}
Array.Clear(progresses, 0, progresses.Length);
hasProgress = false;
}
}
public static class RequestEventHelper
{
private static ConcurrentQueue<RequestEventInfo> requestEventQueue = new ConcurrentQueue<RequestEventInfo>();
#pragma warning disable 0649
public static Action<RequestEventInfo> OnEvent;
#pragma warning restore
// Low frame rate and high download/upload speed can add more download/upload progress events to dispatch in one frame.
// This can add higher CPU usage as it might cause updating the UI/do other things unnecessary in the same frame.
// To avoid this, instead of calling the events directly, we store the last event's data and call download/upload callbacks only once per frame.
private static ProgressFlattener downloadProgress;
private static ProgressFlattener uploadProgress;
private static List<HTTPRequest> registeredRequests = new List<HTTPRequest>();
public static void EnqueueRequestEvent(RequestEventInfo ev)
{
if (HTTPManager.Logger.IsDiagnostic)
HTTPManager.Logger.Information("RequestEventHelper", "Enqueue " + ev.ToString(), ev.SourceRequest.Context);
requestEventQueue.Enqueue(ev);
}
internal static void Clear()
{
requestEventQueue.Clear();
}
internal static void RegisterRequest(HTTPRequest request) => registeredRequests.Add(request);
internal static void AbortAllRequests()
{
foreach (var request in registeredRequests)
request.Abort();
}
internal static bool UnregisterRequest(HTTPRequest request) => registeredRequests.Remove(request);
internal static void RemoveAllRegisteredRequests() => registeredRequests.Clear();
internal static void ProcessQueue()
{
RequestEventInfo requestEvent;
while (requestEventQueue.TryDequeue(out requestEvent))
{
HTTPRequest source = requestEvent.SourceRequest;
if (HTTPManager.Logger.IsDiagnostic)
HTTPManager.Logger.Information("RequestEventHelper", "Processing request event: " + requestEvent.ToString(), source.Context);
if (OnEvent != null)
{
try
{
using (var _ = new Unity.Profiling.ProfilerMarker(nameof(OnEvent)).Auto())
OnEvent(requestEvent);
}
catch (Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "ProcessQueue", ex, source.Context);
}
}
switch (requestEvent.Event)
{
case RequestEvents.DownloadProgress:
try
{
if (source.DownloadSettings.OnDownloadProgress != null)
{
if (downloadProgress == null)
downloadProgress = new ProgressFlattener();
downloadProgress.InsertOrUpdate(requestEvent, source.DownloadSettings.OnDownloadProgress);
}
}
catch (Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "Process RequestEventQueue - RequestEvents.DownloadProgress", ex, source.Context);
}
break;
case RequestEvents.UploadProgress:
try
{
if (source.UploadSettings.OnUploadProgress != null)
{
if (uploadProgress == null)
uploadProgress = new ProgressFlattener();
uploadProgress.InsertOrUpdate(requestEvent, source.UploadSettings.OnUploadProgress);
}
}
catch (Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "Process RequestEventQueue - RequestEvents.UploadProgress", ex, source.Context);
}
break;
case RequestEvents.QueuedResend:
if (source.IsCancellationRequested || HTTPManager.IsQuitting)
break;
HandleQueued(source);
goto case RequestEvents.Resend;
case RequestEvents.Resend:
source.State = HTTPRequestStates.Initial;
var host = HostManager.GetHostVariant(source);
host.Send(source);
break;
case RequestEvents.Headers:
{
try
{
var response = source.Response;
if (source.DownloadSettings.OnHeadersReceived != null && response != null)
source.DownloadSettings.OnHeadersReceived(source, response, requestEvent.Headers);
}
catch (Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "Process RequestEventQueue - RequestEvents.Headers", ex, source.Context);
}
break;
}
case RequestEvents.DownloadStarted:
try
{
// It's possible that response.DownStream is already null when this event is handled!
var response = source.Response;
var downStream = response?.DownStream;
if (response != null && downStream != null)
source.DownloadSettings.OnDownloadStarted?.Invoke(source, response, response.DownStream);
}
catch(Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "DownloadStarted", ex, source.Context);
}
break;
case RequestEvents.Timing:
requestEvent.timingEvent.SourceRequest.Timing.AddEvent(requestEvent.timingEvent);
break;
case RequestEvents.SetState:
// In a case where the request is aborted its state is set to a >= Finished state then,
// on another thread the request processing will fail too queuing up a >= Finished state again.
if (source.State >= HTTPRequestStates.Finished && requestEvent.State >= HTTPRequestStates.Finished)
continue;
// It's different from the next condition! (this is >= and the next is only >)
if (requestEvent.State >= HTTPRequestStates.Finished)
source?.Response?.DownStream?.CompleteAdding(requestEvent.Error);
if (requestEvent.State > HTTPRequestStates.Finished)
{
HTTPManager.Logger.Information("RequestEventHelper", $"{requestEvent.State}: discarding response!", source.Response?.Context ?? source.Context);
source.Response?.Dispose();
source.Response = null;
}
source.Exception = requestEvent.Error;
source.State = requestEvent.State;
// https://www.rfc-editor.org/rfc/rfc5861.html#section-1
// The stale-if-error HTTP Cache-Control extension allows a cache to
// return a stale response when an error -- e.g., a 500 Internal Server
// Error, a network segment, or DNS failure -- is encountered, rather
// than returning a "hard" error.
if (requestEvent.State > HTTPRequestStates.Finished && requestEvent.State != HTTPRequestStates.Aborted)
{
if (HTTPManager.LocalCache != null && !source.DownloadSettings.DisableCache)
{
var hash = Caching.HTTPCache.CalculateHash(source.MethodType, source.CurrentUri);
if (HTTPManager.LocalCache.CanServeWithoutValidation(hash, ErrorTypeForValidation.ConnectionError, source.Context))
{
HTTPManager.LocalCache.Redirect(source, hash);
goto case RequestEvents.Resend;
}
}
}
goto case RequestEvents.StateChange;
case RequestEvents.StateChange:
try
{
using (var _ = new Unity.Profiling.ProfilerMarker(nameof(RequestEventHelper.HandleRequestStateChange)).Auto())
RequestEventHelper.HandleRequestStateChange(ref requestEvent);
}
catch(Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "HandleRequestStateChange", ex, source.Context);
}
break;
}
}
uploadProgress?.DispatchProgressCallbacks();
downloadProgress?.DispatchProgressCallbacks();
}
// TODO: don't start/repeat if can't time out?
private static bool AbortRequestWhenTimedOut(DateTime now, object context)
{
HTTPRequest request = context as HTTPRequest;
if (request.State >= HTTPRequestStates.Finished || request.IsCancellationRequested)
return false; // don't repeat
var downStream = request.Response?.DownStream;
if (downStream != null && downStream.DoFullCheck(limit: 2))
{
var warning = $"Request's download stream is full({downStream.Length:N0}/{downStream.MaxBuffered:N0}) without any Read attempt! You can either increase HTTPRequest's DownloadSettings.ContentStreamMaxBuffered or use streaming. Request's uri: {request.Uri}. See https://bestdocshub.pages.dev/HTTP/getting-started/downloads/ for more details!";
if (HTTPManager.Logger.IsDiagnostic)
HTTPManager.Logger.Warning(nameof(RequestEventHelper), warning, request.Context);
else
UnityEngine.Debug.Log(warning);
// increase buffer limit
downStream.EmergencyIncreaseMaxBuffered();
return false;
}
// Upgradable protocols will shut down themselves
if (request?.Response?.IsUpgraded is bool upgraded && upgraded)
return false;
if (request.TimeoutSettings.IsTimedOut(HTTPManager.CurrentFrameDateTime))
{
HTTPManager.Logger.Information("RequestEventHelper", "AbortRequestWhenTimedOut - Request timed out. CurrentUri: " + request.CurrentUri.ToString(), request.Context);
request.Abort();
return false; // don't repeat
}
return true; // repeat
}
private static void HandleQueued(HTTPRequest source)
{
source.Timing.StartNext(TimingEventNames.Queued);
source.TimeoutSettings.QueuedAt = HTTPManager.CurrentFrameDateTime;
Timer.Add(new TimerData(TimeSpan.FromSeconds(1), source, AbortRequestWhenTimedOut));
}
static readonly string[] RequestStateNames = new string[] { "Initial", "Queued", "Processing", "Finished", "Error", "Aborted", "ConnectionTimedOut", "TimedOut" };
private static void HandleRequestStateChange(ref RequestEventInfo @event)
{
HTTPRequest source = @event.SourceRequest;
// Because there's a race condition between setting the request's State in its Abort() function running on Unity's main thread
// and the HTTP1/HTTP2 handlers running on an another one.
// Because of these race conditions cases violating expectations can be:
// 1.) State is finished but the response null
// 2.) State is (Connection)TimedOut and the response non-null
// We have to make sure that no callbacks are called twice and in the request must be in a consistent state!
// State | Request
// --------- +---------
// 1 Null
// Finished | Skip
// Timeout/Abort | Deliver
//
// 2 Non-Null
// Finished | Deliver
// Timeout/Abort | Skip
using var _ = new Unity.Profiling.ProfilerMarker(RequestStateNames[(int)@event.State]).Auto();
switch (@event.State)
{
case HTTPRequestStates.Queued:
HandleQueued(source);
break;
case HTTPRequestStates.ConnectionTimedOut:
case HTTPRequestStates.TimedOut:
case HTTPRequestStates.Error:
case HTTPRequestStates.Aborted:
if (HTTPManager.Logger.IsDiagnostic)
HTTPManager.Logger.Information("RequestEventHelper", $"{@event.State}: discarding response!", source.Response?.Context ?? source.Context);
source.Response?.Dispose();
source.Response = null;
goto case HTTPRequestStates.Finished;
case HTTPRequestStates.Finished:
// Dispatch any collected download/upload progress, otherwise they would _after_ the callback!
uploadProgress?.DispatchProgressCallbacks();
downloadProgress?.DispatchProgressCallbacks();
if (source.Callback != null)
{
source.Timing.AddEvent(new TimingEventInfo(source, TimingEvents.StartNext, TimingEventNames.Callback));
try
{
if (registeredRequests.Contains(source))
{
using (var __ = new Unity.Profiling.ProfilerMarker(nameof(source.Callback)).Auto())
source.Callback(source, source.Response);
}
}
catch (Exception ex)
{
HTTPManager.Logger.Exception("RequestEventHelper", "HandleRequestStateChange " + @event.State, ex, source.Context);
}
}
source.Timing.AddEvent(new TimingEventInfo(source, TimingEvents.Finish, null));
if (source.Callback == null)
{
// This delay required because with coroutines these lines are executed first
// before the coroutine has a chance to do something with a finished request.
// By adding a delay there's a time window that the coroutine can run its logic too inbetween.
Timer.Add(new TimerData(TimeSpan.FromSeconds(1), source, OnDelayedDisposeTimer));
}
else
{
if (HTTPManager.Logger.IsDiagnostic)
HTTPManager.Logger.Information("RequestEventHelper", "disposing response!", source.Context);
source.Dispose();
}
HostManager.GetHostVariant(source)
.TryToSendQueuedRequests();
break;
}
}
private static bool OnDelayedDisposeTimer(DateTime time, object request)
{
var source = request as HTTPRequest;
if (HTTPManager.Logger.IsDiagnostic)
HTTPManager.Logger.Information("RequestEventHelper", $"{nameof(OnDelayedDisposeTimer)} - disposing response!", source.Context);
source.Dispose();
return false;
}
}
}