541 lines
22 KiB
C#
541 lines
22 KiB
C#
using Best.HTTP.Request.Timings;
|
|
using Best.HTTP.Response.Decompression;
|
|
using Best.HTTP.Shared;
|
|
using Best.HTTP.Shared.Extensions;
|
|
using Best.HTTP.Shared.PlatformSupport.Memory;
|
|
using Best.HTTP.Shared.Streams;
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
|
|
using static Best.HTTP.Hosts.Connections.HTTP1.Constants;
|
|
using static Best.HTTP.Response.HTTPStatusCodes;
|
|
|
|
namespace Best.HTTP.Hosts.Connections.HTTP1
|
|
{
|
|
/// <summary>
|
|
/// An HTTP 1.1 response implementation that can utilize a peekable stream.
|
|
/// Its main entry point is the ProcessPeekable method that should be called after every chunk of data downloaded.
|
|
/// </summary>
|
|
public class PeekableHTTP1Response : HTTPResponse
|
|
{
|
|
public PeekableReadState ReadState
|
|
{
|
|
get => this._readState;
|
|
private set
|
|
{
|
|
if (this._readState != value && HTTPManager.Logger.IsDiagnostic)
|
|
HTTPManager.Logger.Information(nameof(PeekableHTTP1Response), $"{this._readState} => {value}", this.Context);
|
|
this._readState = value;
|
|
}
|
|
}
|
|
private PeekableReadState _readState;
|
|
|
|
public bool ForceDepleteContent;
|
|
|
|
public enum ContentDeliveryMode
|
|
{
|
|
Raw,
|
|
RawUnknownLength,
|
|
Chunked,
|
|
}
|
|
|
|
public enum PeekableReadState
|
|
{
|
|
StatusLine,
|
|
Headers,
|
|
WaitForContentSent, // when received a 100-continue
|
|
PrepareForContent,
|
|
ContentSetup,
|
|
Content,
|
|
Finished
|
|
}
|
|
|
|
public ContentDeliveryMode DeliveryMode => this._deliveryMode;
|
|
private ContentDeliveryMode _deliveryMode;
|
|
private long _expectedLength;
|
|
private Dictionary<string, List<string>> _newHeaders;
|
|
|
|
long _downloaded = 0;
|
|
IDecompressor _decompressor = null;
|
|
bool _compressed = false;
|
|
bool sendProgressChanged;
|
|
|
|
int _chunkLength = -1;
|
|
|
|
enum ReadChunkedStates
|
|
{
|
|
ReadChunkLength,
|
|
ReadChunk,
|
|
ReadTrailingCRLF,
|
|
ReadTrailingHeaders
|
|
}
|
|
ReadChunkedStates _readChunkedState = ReadChunkedStates.ReadChunkLength;
|
|
|
|
IDownloadContentBufferAvailable _bufferAvailableHandler;
|
|
|
|
public PeekableHTTP1Response(HTTPRequest request, bool isFromCache, IDownloadContentBufferAvailable bufferAvailableHandler)
|
|
: base(request, isFromCache)
|
|
{
|
|
this._bufferAvailableHandler = bufferAvailableHandler;
|
|
}
|
|
|
|
private int _isProccessing;
|
|
|
|
public void ProcessPeekable(PeekableContentProviderStream peekable)
|
|
{
|
|
// To avoid executing ProcessPeekable in parallel on two threads, do an atomic CompareExchange and return if the old value wasn't 0.
|
|
if (Interlocked.CompareExchange(ref this._isProccessing, 1, 0) != 0)
|
|
return;
|
|
|
|
if (HTTPManager.Logger.IsDiagnostic)
|
|
HTTPManager.Logger.Verbose(nameof(PeekableHTTP1Response), $"ProcessPeekable({this.ReadState}, {peekable.Length})", this.Context);
|
|
|
|
try
|
|
{
|
|
// The first call after setting it to PeekableReadState.WaitForContentSent is after the the client could send its content.
|
|
// This also works when "If the request did not contain an Expect header field containing the 100-continue expectation,
|
|
// the client can simply discard this interim response."
|
|
// (https://www.rfc-editor.org/rfc/rfc9110#section-15.2.1-3)
|
|
if (this._readState == PeekableReadState.WaitForContentSent)
|
|
{
|
|
this._newHeaders?.Clear();
|
|
this.Headers?.Clear();
|
|
|
|
this._readState = PeekableReadState.StatusLine;
|
|
}
|
|
|
|
// It's an unexpected network closure, except when we reading the content in the RawUnknownLength delivery mode.
|
|
if (peekable == null && ReadState != PeekableReadState.Content && this._deliveryMode != ContentDeliveryMode.RawUnknownLength)
|
|
throw new Exception("Server closed the connection unexpectedly!");
|
|
|
|
switch (ReadState)
|
|
{
|
|
case PeekableReadState.StatusLine:
|
|
if (!IsNewLinePresent(peekable))
|
|
return;
|
|
|
|
Request.Timing.StartNext(TimingEventNames.Headers);
|
|
|
|
var statusLine = HTTPResponse.ReadTo(peekable, (byte)' ');
|
|
string[] versions = statusLine.Split(new char[] { '/', '.' });
|
|
|
|
this.HTTPVersion = new Version(int.Parse(versions[1]), int.Parse(versions[2]));
|
|
|
|
int statusCode;
|
|
string statusCodeStr = NoTrimReadTo(peekable, (byte)' ', LF);
|
|
|
|
if (!int.TryParse(statusCodeStr, out statusCode))
|
|
throw new Exception($"Couldn't parse '{statusCodeStr}' as a status code!");
|
|
|
|
this.StatusCode = statusCode;
|
|
|
|
if (statusCodeStr.Length > 0 && (byte)statusCodeStr[statusCodeStr.Length - 1] != LF && (byte)statusCodeStr[statusCodeStr.Length - 1] != CR)
|
|
this.Message = ReadTo(peekable, LF);
|
|
else
|
|
{
|
|
HTTPManager.Logger.Warning(nameof(PeekableHTTP1Response), "Skipping Status Message reading!", this.Context);
|
|
|
|
this.Message = string.Empty;
|
|
}
|
|
|
|
if (HTTPManager.Logger.IsDiagnostic)
|
|
HTTPManager.Logger.Verbose(nameof(PeekableHTTP1Response), $"HTTP/'{this.HTTPVersion}' '{this.StatusCode}' '{this.Message}'", this.Context);
|
|
|
|
if (this.Request?.DownloadSettings?.OnHeadersReceived != null)
|
|
this._newHeaders = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
this.ReadState = PeekableReadState.Headers;
|
|
goto case PeekableReadState.Headers;
|
|
|
|
case PeekableReadState.Headers:
|
|
ProcessReadHeaders(peekable, PeekableReadState.PrepareForContent);
|
|
if (this.ReadState == PeekableReadState.PrepareForContent)
|
|
{
|
|
#if !UNITY_WEBGL || UNITY_EDITOR
|
|
// When upgraded, we don't want to read the content here, so set the state to Finished.
|
|
if (this.StatusCode == 101 && (HasHeaderWithValue("connection", "upgrade") || HasHeader("upgrade")) && this.Request?.DownloadSettings?.OnUpgraded != null)
|
|
{
|
|
HTTPManager.Logger.Information(nameof(PeekableHTTP1Response), "Request Upgraded!", this.Context);
|
|
|
|
this.IsUpgraded = this.Request.DownloadSettings.OnUpgraded(this.Request, this, peekable);
|
|
|
|
if (this.IsUpgraded)
|
|
{
|
|
this._readState = PeekableReadState.Finished;
|
|
goto case PeekableReadState.Finished;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// If it's a 100-continue, restart reading the response after the client could send its content.
|
|
if (this.StatusCode == Continue)
|
|
{
|
|
this._readState = PeekableReadState.WaitForContentSent;
|
|
break;
|
|
}
|
|
|
|
// https://www.rfc-editor.org/rfc/rfc9110#name-informational-1xx
|
|
// A 1xx response is terminated by the end of the header section; it cannot contain content or trailers.
|
|
if ((this.StatusCode >= Continue && this.StatusCode < OK) ||
|
|
|
|
// https://www.rfc-editor.org/rfc/rfc9110#name-204-no-content
|
|
// A 204 response is terminated by the end of the header section; it cannot contain content or trailers.
|
|
this.StatusCode == NoContent ||
|
|
|
|
// https://www.rfc-editor.org/rfc/rfc9110#name-304-not-modified
|
|
// A 304 response is terminated by the end of the header section; it cannot contain content or trailers.
|
|
this.StatusCode == NotModified ||
|
|
|
|
// https://www.rfc-editor.org/rfc/rfc7230#section-3.3
|
|
// Responses to the HEAD request method (Section 4.3.2
|
|
// of [RFC7231]) never include a message body because the associated
|
|
// response header fields (e.g., Transfer-Encoding, Content-Length,
|
|
// etc.), if present, indicate only what their values would have been if
|
|
// the request method had been GET
|
|
this.Request.MethodType == HTTPMethods.Head)
|
|
{
|
|
this._readState = PeekableReadState.Finished;
|
|
goto case PeekableReadState.Finished;
|
|
}
|
|
|
|
Request.Timing.StartNext(TimingEventNames.Response_Received);
|
|
|
|
// if not an upgraded response, or OnUpgraded returned false, go for the content too.
|
|
goto case PeekableReadState.PrepareForContent;
|
|
}
|
|
break;
|
|
|
|
case PeekableReadState.PrepareForContent:
|
|
BeginReceiveContent();
|
|
|
|
// A content-length header might come with chunked transfer-encoding too.
|
|
var contentLengthHeader = GetFirstHeaderValue("content-length");
|
|
long.TryParse(contentLengthHeader, out this._expectedLength);
|
|
|
|
if (HasHeaderWithValue("transfer-encoding", "chunked") && string.IsNullOrEmpty(contentLengthHeader))
|
|
{
|
|
this._deliveryMode = ContentDeliveryMode.Chunked;
|
|
this.ReadState = PeekableReadState.ContentSetup;
|
|
}
|
|
else
|
|
{
|
|
this._deliveryMode = ContentDeliveryMode.Raw;
|
|
this.ReadState = PeekableReadState.ContentSetup;
|
|
var contentRangeHeaders = GetHeaderValues("content-range");
|
|
|
|
if (contentLengthHeader == null && contentRangeHeaders == null)
|
|
{
|
|
this._deliveryMode = ContentDeliveryMode.RawUnknownLength;
|
|
}
|
|
else if (contentLengthHeader == null && contentRangeHeaders != null)
|
|
{
|
|
HTTPRange range = GetRange();
|
|
|
|
this._expectedLength = (range.LastBytePos - range.FirstBytePos) + 1;
|
|
}
|
|
}
|
|
|
|
if (HTTPManager.Logger.IsDiagnostic)
|
|
HTTPManager.Logger.Information(nameof(PeekableHTTP1Response), $"PrepareForContent - delivery mode selected: {this._deliveryMode}, {this._expectedLength}!", this.Context);
|
|
|
|
CreateDownloadStream(this._bufferAvailableHandler);
|
|
|
|
string encoding = IsFromCache ? null : GetFirstHeaderValue("content-encoding");
|
|
|
|
#if !UNITY_WEBGL || UNITY_EDITOR
|
|
this._compressed = !string.IsNullOrEmpty(encoding);
|
|
|
|
// https://github.com/Benedicht/BestHTTP-Issues/issues/183
|
|
// If _decompressor is still null, remove the compressed flag and serve the content as-is.
|
|
if ((this._decompressor = DecompressorFactory.GetDecompressor(encoding, this.Context)) == null)
|
|
this._compressed = false;
|
|
#endif
|
|
|
|
this.sendProgressChanged = this.Request.DownloadSettings.OnDownloadProgress != null && this.IsSuccess;
|
|
|
|
this.ReadState = PeekableReadState.Content;
|
|
goto case PeekableReadState.Content;
|
|
|
|
case PeekableReadState.Content:
|
|
var downStream = this.DownStream;
|
|
if (downStream != null && downStream.MaxBuffered <= downStream.Length)
|
|
return;
|
|
|
|
switch (this._deliveryMode)
|
|
{
|
|
case ContentDeliveryMode.Raw: ProcessReadRaw(peekable); break;
|
|
case ContentDeliveryMode.RawUnknownLength: ProcessReadRawUnknownLength(peekable); break;
|
|
case ContentDeliveryMode.Chunked: ProcessReadChunked(peekable); break;
|
|
}
|
|
|
|
if (this.ReadState == PeekableReadState.Finished)
|
|
goto case PeekableReadState.Finished;
|
|
break;
|
|
|
|
case PeekableReadState.Finished:
|
|
//baseRequest.Timing.StartNext(TimingEventNames.Queued_For_Disptach);
|
|
break;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref this._isProccessing, 0);
|
|
}
|
|
}
|
|
|
|
bool IsNewLinePresent(PeekableStream peekable)
|
|
{
|
|
peekable.BeginPeek();
|
|
|
|
int nextByte = peekable.PeekByte();
|
|
while (nextByte >= 0 && nextByte != 0x0A)
|
|
nextByte = peekable.PeekByte();
|
|
|
|
return nextByte == 0x0A;
|
|
}
|
|
|
|
private void ProcessReadHeaders(PeekableStream peekable, PeekableReadState targetState)
|
|
{
|
|
if (!IsNewLinePresent(peekable))
|
|
return;
|
|
|
|
do
|
|
{
|
|
string headerName = ReadTo(peekable, (byte)':', LF);
|
|
if (headerName == string.Empty)
|
|
{
|
|
this.ReadState = targetState;
|
|
|
|
if (this.Request?.DownloadSettings?.OnHeadersReceived != null)
|
|
RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this.Request, this._newHeaders));
|
|
return;
|
|
}
|
|
|
|
string value = ReadTo(peekable, LF);
|
|
|
|
if (HTTPManager.Logger.IsDiagnostic)
|
|
HTTPManager.Logger.Verbose(nameof(PeekableHTTP1Response), $"Header - '{headerName}': '{value}'", this.Context);
|
|
|
|
AddHeader(headerName, value);
|
|
|
|
if (this._newHeaders != null)
|
|
{
|
|
List<string> values;
|
|
if (!this._newHeaders.TryGetValue(headerName, out values))
|
|
this._newHeaders.Add(headerName, values = new List<string>(1));
|
|
|
|
values.Add(value);
|
|
}
|
|
} while (IsNewLinePresent(peekable));
|
|
}
|
|
|
|
private void ProcessReadRawUnknownLength(PeekableStream peekable)
|
|
{
|
|
if (peekable == null)
|
|
{
|
|
if (sendProgressChanged)
|
|
RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this.Request, RequestEvents.DownloadProgress, this._downloaded, this._expectedLength));
|
|
|
|
PostProcessContent();
|
|
|
|
this.ReadState = PeekableReadState.Finished;
|
|
|
|
return;
|
|
}
|
|
|
|
while (peekable.Length > 0)
|
|
{
|
|
var buffer = BufferPool.Get(64 * 1024, true, this.Context);
|
|
|
|
var readCount = peekable.Read(buffer, 0, buffer.Length);
|
|
|
|
ProcessChunk(buffer.AsBuffer(readCount));
|
|
}
|
|
|
|
if (sendProgressChanged)
|
|
RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this.Request, RequestEvents.DownloadProgress, this._downloaded, this._expectedLength));
|
|
}
|
|
|
|
private bool TryReadChunkLength(PeekableStream peekable, out int result)
|
|
{
|
|
result = -1;
|
|
if (!IsNewLinePresent(peekable))
|
|
return false;
|
|
|
|
// Read until the end of line, then split the string so we will discard any optional chunk extensions
|
|
|
|
var buff = ReadToAsByte(peekable, LF);
|
|
|
|
if (buff == BufferSegment.Empty)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var chars = MemoryMarshal.Cast<byte, char>(buff.AsSpan());
|
|
|
|
var idx = chars.IndexOf(';');
|
|
|
|
if (idx == -1)
|
|
idx = chars.Length;
|
|
|
|
return int.TryParse(chars.Slice(0, idx), System.Globalization.NumberStyles.AllowHexSpecifier, null, out result);
|
|
}
|
|
finally
|
|
{
|
|
BufferPool.Release(buff);
|
|
}
|
|
}
|
|
|
|
void ProcessReadChunked(PeekableStream peekable)
|
|
{
|
|
switch(this._readChunkedState)
|
|
{
|
|
case ReadChunkedStates.ReadChunkLength:
|
|
this._readChunkedState = ReadChunkedStates.ReadChunkLength;
|
|
|
|
if (TryReadChunkLength(peekable, out this._chunkLength))
|
|
{
|
|
if (this._chunkLength == 0)
|
|
{
|
|
PostProcessContent();
|
|
|
|
if (this.Request?.DownloadSettings?.OnHeadersReceived != null)
|
|
this._newHeaders = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
|
goto case ReadChunkedStates.ReadTrailingHeaders;
|
|
}
|
|
|
|
goto case ReadChunkedStates.ReadChunk;
|
|
}
|
|
break;
|
|
|
|
case ReadChunkedStates.ReadChunk:
|
|
this._readChunkedState = ReadChunkedStates.ReadChunk;
|
|
|
|
while (this._chunkLength > 0 && peekable.Length > 0)
|
|
{
|
|
int targetReadCount = Math.Min(Math.Min(64 * 1024, this._chunkLength), (int)peekable.Length);
|
|
|
|
var buffer = BufferPool.Get(targetReadCount, true, this.Context);
|
|
|
|
var readCount = peekable.Read(buffer, 0, targetReadCount);
|
|
|
|
if (readCount < 0)
|
|
{
|
|
BufferPool.Release(buffer);
|
|
throw ExceptionHelper.ServerClosedTCPStream();
|
|
}
|
|
|
|
this._chunkLength -= readCount;
|
|
|
|
ProcessChunk(buffer.AsBuffer(readCount));
|
|
}
|
|
|
|
if (sendProgressChanged)
|
|
RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this.Request, RequestEvents.DownloadProgress, this._downloaded, this._expectedLength));
|
|
|
|
// Every chunk data has a trailing CRLF
|
|
if (this._chunkLength == 0)
|
|
goto case ReadChunkedStates.ReadTrailingCRLF;
|
|
break;
|
|
|
|
case ReadChunkedStates.ReadTrailingCRLF:
|
|
this._readChunkedState = ReadChunkedStates.ReadTrailingCRLF;
|
|
|
|
if (IsNewLinePresent(peekable))
|
|
{
|
|
BufferPool.Release(HTTPResponse.ReadToAsByte(peekable, LF));
|
|
|
|
goto case ReadChunkedStates.ReadChunkLength;
|
|
}
|
|
break;
|
|
|
|
case ReadChunkedStates.ReadTrailingHeaders:
|
|
this._readChunkedState = ReadChunkedStates.ReadTrailingHeaders;
|
|
|
|
ProcessReadHeaders(peekable, PeekableReadState.Finished);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void ProcessReadRaw(PeekableStream peekable)
|
|
{
|
|
if (this.DownStream == null)
|
|
throw new ArgumentNullException(nameof(this.DownStream));
|
|
if (peekable == null)
|
|
throw new ArgumentNullException(nameof(peekable));
|
|
|
|
while (peekable.Length > 0 && !this.DownStream.IsFull)
|
|
{
|
|
var buffer = BufferPool.Get(64 * 1024, true, this.Context);
|
|
|
|
var readCount = peekable.Read(buffer, 0, buffer.Length);
|
|
|
|
if (readCount < 0)
|
|
{
|
|
BufferPool.Release(buffer);
|
|
throw ExceptionHelper.ServerClosedTCPStream();
|
|
}
|
|
|
|
ProcessChunk(buffer.AsBuffer(readCount));
|
|
}
|
|
|
|
if (sendProgressChanged)
|
|
RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this.Request, RequestEvents.DownloadProgress, this._downloaded, this._expectedLength));
|
|
|
|
if (this._downloaded >= this._expectedLength)
|
|
{
|
|
PostProcessContent();
|
|
this.ReadState = PeekableReadState.Finished;
|
|
}
|
|
}
|
|
|
|
void ProcessChunk(BufferSegment chunk)
|
|
{
|
|
this._downloaded += chunk.Count;
|
|
|
|
if (this._compressed)
|
|
{
|
|
var (decompressed, release) = this._decompressor.Decompress(chunk, false, true, this.Context);
|
|
if (decompressed != BufferSegment.Empty)
|
|
FeedDownloadedContentChunk(decompressed);
|
|
|
|
//if (decompressed.Data != chunk.Data)
|
|
if (release)
|
|
BufferPool.Release(chunk);
|
|
}
|
|
else
|
|
{
|
|
FeedDownloadedContentChunk(chunk);
|
|
}
|
|
}
|
|
|
|
void PostProcessContent()
|
|
{
|
|
if (this._compressed)
|
|
{
|
|
var (decompressed, release) = this._decompressor.Decompress(BufferSegment.Empty, true, true, this.Context);
|
|
if (decompressed != BufferSegment.Empty)
|
|
FeedDownloadedContentChunk(decompressed);
|
|
}
|
|
|
|
FinishedContentReceiving();
|
|
|
|
if (this._decompressor != null)
|
|
{
|
|
this._decompressor.Dispose();
|
|
this._decompressor = null;
|
|
}
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
|
|
this._decompressor?.Dispose();
|
|
}
|
|
}
|
|
}
|